Przeglądaj źródła

Reformat with black

Dan Braghis 3 lat temu
rodzic
commit
d10f15e558
100 zmienionych plików z 7360 dodań i 4399 usunięć
  1. 30 17
      conftest.py
  2. 47 54
      docs/conf.py
  3. 45 35
      runtests.py
  4. 10 6
      scripts/check-translation-strings.py
  5. 12 12
      scripts/get-translator-credits.py
  6. 8 7
      scripts/nightly/get_version.py
  7. 16 10
      scripts/nightly/upload.py
  8. 56 65
      setup.py
  9. 1 2
      wagtail/__init__.py
  10. 1 2
      wagtail/admin/__init__.py
  11. 134 112
      wagtail/admin/action_menu.py
  12. 13 5
      wagtail/admin/admin_url_finder.py
  13. 4 1
      wagtail/admin/api/actions/convert_alias.py
  14. 9 9
      wagtail/admin/api/actions/copy.py
  15. 3 1
      wagtail/admin/api/actions/copy_for_translation.py
  16. 4 1
      wagtail/admin/api/actions/create_alias.py
  17. 12 2
      wagtail/admin/api/actions/move.py
  18. 3 1
      wagtail/admin/api/actions/revert_to_page_revision.py
  19. 9 6
      wagtail/admin/api/filters.py
  20. 57 18
      wagtail/admin/api/serializers.py
  21. 4 5
      wagtail/admin/api/urls.py
  22. 43 36
      wagtail/admin/api/views.py
  23. 4 3
      wagtail/admin/apps.py
  24. 25 15
      wagtail/admin/auth.py
  25. 3 2
      wagtail/admin/blocks.py
  26. 65 49
      wagtail/admin/checks.py
  27. 135 82
      wagtail/admin/compare.py
  28. 256 178
      wagtail/admin/edit_handlers.py
  29. 25 15
      wagtail/admin/filters.py
  30. 6 2
      wagtail/admin/forms/__init__.py
  31. 44 35
      wagtail/admin/forms/account.py
  32. 19 13
      wagtail/admin/forms/auth.py
  33. 1 1
      wagtail/admin/forms/choosers.py
  34. 97 68
      wagtail/admin/forms/collections.py
  35. 15 6
      wagtail/admin/forms/comments.py
  36. 11 9
      wagtail/admin/forms/models.py
  37. 96 51
      wagtail/admin/forms/pages.py
  38. 2 2
      wagtail/admin/forms/search.py
  39. 7 4
      wagtail/admin/forms/tags.py
  40. 21 11
      wagtail/admin/forms/view_restrictions.py
  41. 51 32
      wagtail/admin/forms/workflows.py
  42. 5 4
      wagtail/admin/jinja2tags.py
  43. 122 123
      wagtail/admin/localization.py
  44. 121 76
      wagtail/admin/mail.py
  45. 52 22
      wagtail/admin/menu.py
  46. 19 16
      wagtail/admin/messages.py
  47. 16 15
      wagtail/admin/migrations/0001_create_admin_access_permissions.py
  48. 14 6
      wagtail/admin/migrations/0002_admin.py
  49. 14 6
      wagtail/admin/migrations/0003_admin_managed.py
  50. 10 4
      wagtail/admin/modal_workflow.py
  51. 22 16
      wagtail/admin/models.py
  52. 8 9
      wagtail/admin/navigation.py
  53. 15 13
      wagtail/admin/rich_text/__init__.py
  54. 55 38
      wagtail/admin/rich_text/converters/contentstate.py
  55. 21 19
      wagtail/admin/rich_text/converters/contentstate_models.py
  56. 28 22
      wagtail/admin/rich_text/converters/editor_html.py
  57. 17 10
      wagtail/admin/rich_text/converters/html_ruleset.py
  58. 104 66
      wagtail/admin/rich_text/converters/html_to_contentstate.py
  59. 17 16
      wagtail/admin/rich_text/editors/draftail/__init__.py
  60. 8 4
      wagtail/admin/rich_text/editors/draftail/features.py
  61. 66 48
      wagtail/admin/rich_text/editors/hallo.py
  62. 31 18
      wagtail/admin/search.py
  63. 30 8
      wagtail/admin/signal_handlers.py
  64. 0 1
      wagtail/admin/signals.py
  65. 10 10
      wagtail/admin/site_summary.py
  66. 4 5
      wagtail/admin/staticfiles.py
  67. 261 187
      wagtail/admin/templatetags/wagtailadmin_tags.py
  68. 31 20
      wagtail/admin/templatetags/wagtailuserbar.py
  69. 62 41
      wagtail/admin/tests/api/test_documents.py
  70. 144 92
      wagtail/admin/tests/api/test_images.py
  71. 485 264
      wagtail/admin/tests/api/test_pages.py
  72. 28 16
      wagtail/admin/tests/benches.py
  73. 8 1
      wagtail/admin/tests/pages/test_bulk_actions/test_bulk_action.py
  74. 97 47
      wagtail/admin/tests/pages/test_bulk_actions/test_bulk_delete.py
  75. 150 81
      wagtail/admin/tests/pages/test_bulk_actions/test_bulk_move.py
  76. 107 32
      wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py
  77. 89 31
      wagtail/admin/tests/pages/test_bulk_actions/test_bulk_unpublish.py
  78. 8 5
      wagtail/admin/tests/pages/test_content_type_use_view.py
  79. 33 11
      wagtail/admin/tests/pages/test_convert_alias.py
  80. 301 196
      wagtail/admin/tests/pages/test_copy_page.py
  81. 427 187
      wagtail/admin/tests/pages/test_create_page.py
  82. 32 24
      wagtail/admin/tests/pages/test_dashboard.py
  83. 86 32
      wagtail/admin/tests/pages/test_delete_page.py
  84. 371 228
      wagtail/admin/tests/pages/test_edit_page.py
  85. 275 170
      wagtail/admin/tests/pages/test_explorer_view.py
  86. 94 47
      wagtail/admin/tests/pages/test_moderation.py
  87. 95 47
      wagtail/admin/tests/pages/test_move_page.py
  88. 82 38
      wagtail/admin/tests/pages/test_page_locking.py
  89. 85 61
      wagtail/admin/tests/pages/test_page_search.py
  90. 173 104
      wagtail/admin/tests/pages/test_preview.py
  91. 182 107
      wagtail/admin/tests/pages/test_revisions.py
  92. 86 47
      wagtail/admin/tests/pages/test_unpublish_page.py
  93. 24 14
      wagtail/admin/tests/pages/test_view_draft.py
  94. 41 12
      wagtail/admin/tests/pages/test_workflow_history.py
  95. 311 209
      wagtail/admin/tests/test_account_management.py
  96. 45 30
      wagtail/admin/tests/test_admin_search.py
  97. 92 54
      wagtail/admin/tests/test_audit_log.py
  98. 73 51
      wagtail/admin/tests/test_buttons_hooks.py
  99. 209 123
      wagtail/admin/tests/test_collections_views.py
  100. 560 230
      wagtail/admin/tests/test_compare.py

+ 30 - 17
conftest.py

@@ -6,49 +6,62 @@ import django
 
 
 def pytest_addoption(parser):
-    parser.addoption('--deprecation', choices=['all', 'pending', 'imminent', 'none'], default='pending')
-    parser.addoption('--postgres', action='store_true')
-    parser.addoption('--elasticsearch', action='store_true')
+    parser.addoption(
+        "--deprecation",
+        choices=["all", "pending", "imminent", "none"],
+        default="pending",
+    )
+    parser.addoption("--postgres", action="store_true")
+    parser.addoption("--elasticsearch", action="store_true")
 
 
 def pytest_configure(config):
-    deprecation = config.getoption('deprecation')
+    deprecation = config.getoption("deprecation")
 
-    only_wagtail = r'^wagtail(\.|$)'
-    if deprecation == 'all':
+    only_wagtail = r"^wagtail(\.|$)"
+    if deprecation == "all":
         # Show all deprecation warnings from all packages
-        warnings.simplefilter('default', DeprecationWarning)
-        warnings.simplefilter('default', PendingDeprecationWarning)
-    elif deprecation == 'pending':
+        warnings.simplefilter("default", DeprecationWarning)
+        warnings.simplefilter("default", PendingDeprecationWarning)
+    elif deprecation == "pending":
         # Show all deprecation warnings from wagtail
-        warnings.filterwarnings('default', category=DeprecationWarning, module=only_wagtail)
-        warnings.filterwarnings('default', category=PendingDeprecationWarning, module=only_wagtail)
-    elif deprecation == 'imminent':
+        warnings.filterwarnings(
+            "default", category=DeprecationWarning, module=only_wagtail
+        )
+        warnings.filterwarnings(
+            "default", category=PendingDeprecationWarning, module=only_wagtail
+        )
+    elif deprecation == "imminent":
         # Show only imminent deprecation warnings from wagtail
-        warnings.filterwarnings('default', category=DeprecationWarning, module=only_wagtail)
-    elif deprecation == 'none':
+        warnings.filterwarnings(
+            "default", category=DeprecationWarning, module=only_wagtail
+        )
+    elif deprecation == "none":
         # Deprecation warnings are ignored by default
         pass
 
-    if config.getoption('postgres'):
-        os.environ['DATABASE_ENGINE'] = 'django.db.backends.postgresql'
+    if config.getoption("postgres"):
+        os.environ["DATABASE_ENGINE"] = "django.db.backends.postgresql"
 
     # Setup django after processing the pytest arguments so that the env
     # variables are available in the settings
-    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtail.tests.settings')
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "wagtail.tests.settings")
     django.setup()
 
     # Activate a language: This affects HTTP header HTTP_ACCEPT_LANGUAGE sent by
     # the Django test client.
     from django.utils import translation
+
     translation.activate("en")
 
     from wagtail.tests.settings import MEDIA_ROOT, STATIC_ROOT
+
     shutil.rmtree(STATIC_ROOT, ignore_errors=True)
     shutil.rmtree(MEDIA_ROOT, ignore_errors=True)
 
 
 def pytest_unconfigure(config):
     from wagtail.tests.settings import MEDIA_ROOT, STATIC_ROOT
+
     shutil.rmtree(STATIC_ROOT, ignore_errors=True)
     shutil.rmtree(MEDIA_ROOT, ignore_errors=True)

+ 47 - 54
docs/conf.py

@@ -14,40 +14,37 @@
 
 import os
 import sys
-
 from datetime import datetime
 
 import django
 import sphinx_wagtail_theme
-
 from recommonmark.transform import AutoStructify
 
 from wagtail import VERSION, __version__
 
-
 # on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org
-on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+on_rtd = os.environ.get("READTHEDOCS", None) == "True"
 
-html_theme = 'sphinx_wagtail_theme'
+html_theme = "sphinx_wagtail_theme"
 html_theme_path = [sphinx_wagtail_theme.get_html_theme_path()]
 
 html_theme_options = {
     "project_name": "Wagtail Documentation",
-    "github_url": "https://github.com/wagtail/wagtail/blob/main/docs/"
+    "github_url": "https://github.com/wagtail/wagtail/blob/main/docs/",
 }
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
-sys.path.insert(0, os.path.abspath('..'))
+sys.path.insert(0, os.path.abspath(".."))
 
 # Autodoc may need to import some models modules which require django settings
 # be configured
-os.environ['DJANGO_SETTINGS_MODULE'] = 'wagtail.tests.settings'
+os.environ["DJANGO_SETTINGS_MODULE"] = "wagtail.tests.settings"
 django.setup()
 
 # Use SQLite3 database engine so it doesn't attempt to use psycopg2 on RTD
-os.environ['DATABASE_ENGINE'] = 'django.db.backends.sqlite3'
+os.environ["DATABASE_ENGINE"] = "django.db.backends.sqlite3"
 
 # -- General configuration ------------------------------------------------
 
@@ -58,37 +55,37 @@ os.environ['DATABASE_ENGINE'] = 'django.db.backends.sqlite3'
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = [
-    'sphinx.ext.autodoc',
-    'sphinx.ext.intersphinx',
-    'recommonmark',
-    'sphinx_wagtail_theme',
+    "sphinx.ext.autodoc",
+    "sphinx.ext.intersphinx",
+    "recommonmark",
+    "sphinx_wagtail_theme",
 ]
 
 if not on_rtd:
-    extensions.append('sphinxcontrib.spelling')
+    extensions.append("sphinxcontrib.spelling")
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix of source filenames.
-source_suffix = '.rst'
+source_suffix = ".rst"
 
 # The encoding of source files.
 # source_encoding = 'utf-8-sig'
 
 # The master toctree document.
-master_doc = 'index'
+master_doc = "index"
 
 # General information about the project.
-project = 'Wagtail Documentation'
-copyright = f'{datetime.now().year}, Torchbox and contributors'
+project = "Wagtail Documentation"
+copyright = f"{datetime.now().year}, Torchbox and contributors"
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 
 # The short X.Y version.
-version = '{}.{}'.format(VERSION[0], VERSION[1])
+version = "{}.{}".format(VERSION[0], VERSION[1])
 # The full version, including alpha/beta/rc tags.
 release = __version__
 
@@ -104,7 +101,7 @@ release = __version__
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
-exclude_patterns = ['_build', 'README.md']
+exclude_patterns = ["_build", "README.md"]
 
 # The reST default role (used for this markup: `text`) to use for all
 # documents.
@@ -122,7 +119,7 @@ exclude_patterns = ['_build', 'README.md']
 # show_authors = False
 
 # The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'default'
+pygments_style = "default"
 
 # A list of ignored prefixes for module index sorting.
 # modindex_common_prefix = []
@@ -132,12 +129,15 @@ pygments_style = 'default'
 
 # splhinxcontrib.spelling settings
 
-spelling_lang = 'en_GB'
-spelling_word_list_filename = 'spelling_wordlist.txt'
+spelling_lang = "en_GB"
+spelling_word_list_filename = "spelling_wordlist.txt"
 
 # sphinx.ext.intersphinx settings
 intersphinx_mapping = {
-    'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/')
+    "django": (
+        "https://docs.djangoproject.com/en/stable/",
+        "https://docs.djangoproject.com/en/stable/_objects/",
+    )
 }
 
 # -- Options for HTML output ----------------------------------------------
@@ -161,17 +161,17 @@ intersphinx_mapping = {
 # The name of an image file (within the static path) to use as favicon of the
 # docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
 # pixels large.
-html_favicon = 'favicon.ico'
+html_favicon = "favicon.ico"
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
 
 # Add any extra paths that contain custom files (such as robots.txt or
 # .htaccess) here, relative to this directory. These files are copied
 # directly to the root of the documentation.
-html_extra_path = ['robots.txt']
+html_extra_path = ["robots.txt"]
 
 # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
 # using the given strftime format.
@@ -218,17 +218,15 @@ html_use_index = False
 # html_file_suffix = None
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'Wagtaildoc'
+htmlhelp_basename = "Wagtaildoc"
 
 # -- Options for LaTeX output ---------------------------------------------
 
 latex_elements = {
     # The paper size ('letterpaper' or 'a4paper').
     # 'papersize': 'letterpaper',
-
     # The font size ('10pt', '11pt' or '12pt').
     # 'pointsize': '10pt',
-
     # Additional stuff for the LaTeX preamble.
     # 'preamble': '',
 }
@@ -237,13 +235,7 @@ latex_elements = {
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-    (
-        'index',
-        'Wagtail.tex',
-        'Wagtail Documentation',
-        'Torchbox',
-        'manual'
-    ),
+    ("index", "Wagtail.tex", "Wagtail Documentation", "Torchbox", "manual"),
 ]
 
 # The name of an image file (relative to this directory) to place at the top of
@@ -270,10 +262,7 @@ latex_documents = [
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [
-    ('index', 'wagtail', u'Wagtail Documentation',
-     [u'Torchbox'], 1)
-]
+man_pages = [("index", "wagtail", "Wagtail Documentation", ["Torchbox"], 1)]
 
 # If true, show URL addresses after external links.
 # man_show_urls = False
@@ -285,13 +274,13 @@ man_pages = [
 #  dir menu entry, description, category)
 texinfo_documents = [
     (
-        'index',
-        'Wagtail',
-        'Wagtail Documentation',
-        'Torchbox',
-        'Wagtail',
-        'One line description of project.',
-        'Miscellaneous'
+        "index",
+        "Wagtail",
+        "Wagtail Documentation",
+        "Torchbox",
+        "Wagtail",
+        "One line description of project.",
+        "Miscellaneous",
     ),
 ]
 
@@ -309,11 +298,15 @@ texinfo_documents = [
 
 
 def setup(app):
-    app.add_js_file('js/banner.js')
+    app.add_js_file("js/banner.js")
 
-    github_doc_root = 'https://github.com/wagtail/wagtail/tree/main/docs/'
+    github_doc_root = "https://github.com/wagtail/wagtail/tree/main/docs/"
 
-    app.add_config_value('recommonmark_config', {
-        'url_resolver': lambda url: github_doc_root + url,
-    }, True)
+    app.add_config_value(
+        "recommonmark_config",
+        {
+            "url_resolver": lambda url: github_doc_root + url,
+        },
+        True,
+    )
     app.add_transform(AutoStructify)

+ 45 - 35
runtests.py

@@ -8,20 +8,23 @@ import warnings
 
 from django.core.management import execute_from_command_line
 
-
-os.environ['DJANGO_SETTINGS_MODULE'] = 'wagtail.tests.settings'
+os.environ["DJANGO_SETTINGS_MODULE"] = "wagtail.tests.settings"
 
 
 def make_parser():
     parser = argparse.ArgumentParser()
-    parser.add_argument('--deprecation', choices=['all', 'pending', 'imminent', 'none'], default='imminent')
-    parser.add_argument('--postgres', action='store_true')
-    parser.add_argument('--elasticsearch5', action='store_true')
-    parser.add_argument('--elasticsearch6', action='store_true')
-    parser.add_argument('--elasticsearch7', action='store_true')
-    parser.add_argument('--emailuser', action='store_true')
-    parser.add_argument('--disabletimezone', action='store_true')
-    parser.add_argument('--bench', action='store_true')
+    parser.add_argument(
+        "--deprecation",
+        choices=["all", "pending", "imminent", "none"],
+        default="imminent",
+    )
+    parser.add_argument("--postgres", action="store_true")
+    parser.add_argument("--elasticsearch5", action="store_true")
+    parser.add_argument("--elasticsearch6", action="store_true")
+    parser.add_argument("--elasticsearch7", action="store_true")
+    parser.add_argument("--emailuser", action="store_true")
+    parser.add_argument("--disabletimezone", action="store_true")
+    parser.add_argument("--bench", action="store_true")
     return parser
 
 
@@ -32,61 +35,68 @@ def parse_args(args=None):
 def runtests():
     args, rest = parse_args()
 
-    only_wagtail = r'^wagtail(\.|$)'
-    if args.deprecation == 'all':
+    only_wagtail = r"^wagtail(\.|$)"
+    if args.deprecation == "all":
         # Show all deprecation warnings from all packages
-        warnings.simplefilter('default', DeprecationWarning)
-        warnings.simplefilter('default', PendingDeprecationWarning)
-    elif args.deprecation == 'pending':
+        warnings.simplefilter("default", DeprecationWarning)
+        warnings.simplefilter("default", PendingDeprecationWarning)
+    elif args.deprecation == "pending":
         # Show all deprecation warnings from wagtail
-        warnings.filterwarnings('default', category=DeprecationWarning, module=only_wagtail)
-        warnings.filterwarnings('default', category=PendingDeprecationWarning, module=only_wagtail)
-    elif args.deprecation == 'imminent':
+        warnings.filterwarnings(
+            "default", category=DeprecationWarning, module=only_wagtail
+        )
+        warnings.filterwarnings(
+            "default", category=PendingDeprecationWarning, module=only_wagtail
+        )
+    elif args.deprecation == "imminent":
         # Show only imminent deprecation warnings from wagtail
-        warnings.filterwarnings('default', category=DeprecationWarning, module=only_wagtail)
-    elif args.deprecation == 'none':
+        warnings.filterwarnings(
+            "default", category=DeprecationWarning, module=only_wagtail
+        )
+    elif args.deprecation == "none":
         # Deprecation warnings are ignored by default
         pass
 
     if args.postgres:
-        os.environ['DATABASE_ENGINE'] = 'django.db.backends.postgresql'
+        os.environ["DATABASE_ENGINE"] = "django.db.backends.postgresql"
 
     if args.elasticsearch5:
-        os.environ.setdefault('ELASTICSEARCH_URL', 'http://localhost:9200')
-        os.environ.setdefault('ELASTICSEARCH_VERSION', '5')
+        os.environ.setdefault("ELASTICSEARCH_URL", "http://localhost:9200")
+        os.environ.setdefault("ELASTICSEARCH_VERSION", "5")
     elif args.elasticsearch6:
-        os.environ.setdefault('ELASTICSEARCH_URL', 'http://localhost:9200')
-        os.environ.setdefault('ELASTICSEARCH_VERSION', '6')
+        os.environ.setdefault("ELASTICSEARCH_URL", "http://localhost:9200")
+        os.environ.setdefault("ELASTICSEARCH_VERSION", "6")
     elif args.elasticsearch7:
-        os.environ.setdefault('ELASTICSEARCH_URL', 'http://localhost:9200')
-        os.environ.setdefault('ELASTICSEARCH_VERSION', '7')
+        os.environ.setdefault("ELASTICSEARCH_URL", "http://localhost:9200")
+        os.environ.setdefault("ELASTICSEARCH_VERSION", "7")
 
-    elif 'ELASTICSEARCH_URL' in os.environ:
+    elif "ELASTICSEARCH_URL" in os.environ:
         # forcibly delete the ELASTICSEARCH_URL setting to skip those tests
-        del os.environ['ELASTICSEARCH_URL']
+        del os.environ["ELASTICSEARCH_URL"]
 
     if args.emailuser:
-        os.environ['USE_EMAIL_USER_MODEL'] = '1'
+        os.environ["USE_EMAIL_USER_MODEL"] = "1"
 
     if args.disabletimezone:
-        os.environ['DISABLE_TIMEZONE'] = '1'
+        os.environ["DISABLE_TIMEZONE"] = "1"
 
     if args.bench:
         benchmarks = [
-            'wagtail.admin.tests.benches',
+            "wagtail.admin.tests.benches",
         ]
 
-        argv = [sys.argv[0], 'test', '-v2'] + benchmarks + rest
+        argv = [sys.argv[0], "test", "-v2"] + benchmarks + rest
     else:
-        argv = [sys.argv[0], 'test'] + rest
+        argv = [sys.argv[0], "test"] + rest
 
     try:
         execute_from_command_line(argv)
     finally:
         from wagtail.tests.settings import MEDIA_ROOT, STATIC_ROOT
+
         shutil.rmtree(STATIC_ROOT, ignore_errors=True)
         shutil.rmtree(MEDIA_ROOT, ignore_errors=True)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     runtests()

+ 10 - 6
scripts/check-translation-strings.py

@@ -1,13 +1,11 @@
 import re
-
 from pathlib import Path
 
 import polib
 
+placeholder_regexp = re.compile(r"\{[^\}]*?\}")
 
-placeholder_regexp = re.compile(r'\{[^\}]*?\}')
-
-for path in Path(__file__).parent.resolve().parent.rglob('LC_MESSAGES/*.po'):
+for path in Path(__file__).parent.resolve().parent.rglob("LC_MESSAGES/*.po"):
     po = polib.pofile(path)
     for entry in po:
         if not entry.msgstr:
@@ -17,6 +15,12 @@ for path in Path(__file__).parent.resolve().parent.rglob('LC_MESSAGES/*.po'):
         actual_placeholders = set(placeholder_regexp.findall(entry.msgstr))
         if expected_placeholders != actual_placeholders:
             print("Invalid string at %s line %d:" % (path, entry.linenum))
-            print("\toriginal string %r has placeholders: %r" % (entry.msgid, expected_placeholders))
-            print("\ttranslated string %r has placeholders: %r" % (entry.msgstr, actual_placeholders))
+            print(
+                "\toriginal string %r has placeholders: %r"
+                % (entry.msgid, expected_placeholders)
+            )
+            print(
+                "\ttranslated string %r has placeholders: %r"
+                % (entry.msgstr, actual_placeholders)
+            )
             print()

+ 12 - 12
scripts/get-translator-credits.py

@@ -1,37 +1,37 @@
 import re
 import subprocess
-
 from collections import defaultdict
 from io import open
 
 from babel import Locale
 
-
 authors_by_locale = defaultdict(set)
 
-file_listing = subprocess.Popen('find ../wagtail -iname *.po', shell=True, stdout=subprocess.PIPE)
+file_listing = subprocess.Popen(
+    "find ../wagtail -iname *.po", shell=True, stdout=subprocess.PIPE
+)
 
 for file_listing_line in file_listing.stdout:
     filename = file_listing_line.strip()
 
     # extract locale string from filename
-    locale = re.search(r'locale/(\w+)/LC_MESSAGES', str(filename)).group(1)
-    if locale == 'en':
+    locale = re.search(r"locale/(\w+)/LC_MESSAGES", str(filename)).group(1)
+    if locale == "en":
         continue
 
     # read author list from each file
-    with open(filename, 'rt') as f:
+    with open(filename, "rt") as f:
         has_found_translators_heading = False
         for line in f:
             line = line.strip()
-            if line.startswith('#'):
+            if line.startswith("#"):
                 if has_found_translators_heading:
-                    author_match = re.match(r'\# (.*), [\d\-]+', line)
+                    author_match = re.match(r"\# (.*), [\d\-]+", line)
                     if not author_match:
                         break
                     author = author_match.group(1)
                     authors_by_locale[locale].add(author)
-                elif line.startswith('# Translators:'):
+                elif line.startswith("# Translators:"):
                     has_found_translators_heading = True
             else:
                 if has_found_translators_heading:
@@ -41,8 +41,8 @@ for file_listing_line in file_listing.stdout:
 
 
 LANGUAGE_OVERRIDES = {
-    'tet': 'Tetum',
-    'ht': 'Haitian',
+    "tet": "Tetum",
+    "ht": "Haitian",
 }
 
 
@@ -64,4 +64,4 @@ for (language_name, locale) in language_names:
     print("-----")
     for author in sorted(authors_by_locale[locale]):
         print(author)
-    print('')
+    print("")

+ 8 - 7
scripts/nightly/get_version.py

@@ -8,7 +8,6 @@ import datetime
 
 from wagtail import VERSION
 
-
 INIT_TEMPLATE = """
 from wagtail.utils.version import get_semver_version, get_version
 
@@ -23,9 +22,11 @@ __semver__ = get_semver_version(VERSION)
 """
 
 
-print(INIT_TEMPLATE.format(
-    major=VERSION[0],
-    minor=VERSION[1],
-    patch=VERSION[2],
-    datestamp=datetime.date.today().strftime('%Y%m%d'),
-))
+print(
+    INIT_TEMPLATE.format(
+        major=VERSION[0],
+        minor=VERSION[1],
+        patch=VERSION[2],
+        datestamp=datetime.date.today().strftime("%Y%m%d"),
+    )
+)

+ 16 - 10
scripts/nightly/upload.py

@@ -4,24 +4,30 @@ import sys
 
 import boto3
 
-
-dist_folder = pathlib.Path.cwd() / 'dist'
+dist_folder = pathlib.Path.cwd() / "dist"
 
 try:
-    f = next(dist_folder.glob('*.whl'))
+    f = next(dist_folder.glob("*.whl"))
 except StopIteration:
     print("No .whl files found in ./dist!")
     sys.exit()
 
 print("Uploading", f.name)
-s3 = boto3.client('s3')
-s3.upload_file(str(f), 'releases.wagtail.io', 'nightly/dist/' + f.name, ExtraArgs={'ACL': 'public-read'})
+s3 = boto3.client("s3")
+s3.upload_file(
+    str(f),
+    "releases.wagtail.io",
+    "nightly/dist/" + f.name,
+    ExtraArgs={"ACL": "public-read"},
+)
 
 print("Updating latest.json")
 
-boto3.resource('s3').Object('releases.wagtail.io', 'nightly/latest.json').put(
-    ACL='public-read',
-    Body=json.dumps({
-        "url": 'https://releases.wagtail.org/nightly/dist/' + f.name,
-    })
+boto3.resource("s3").Object("releases.wagtail.io", "nightly/latest.json").put(
+    ACL="public-read",
+    Body=json.dumps(
+        {
+            "url": "https://releases.wagtail.org/nightly/dist/" + f.name,
+        }
+    ),
 )

+ 56 - 65
setup.py

@@ -3,7 +3,6 @@
 from wagtail import __version__
 from wagtail.utils.setup import assets, check_bdist_egg, sdist
 
-
 try:
     from setuptools import find_packages, setup
 except ImportError:
@@ -42,61 +41,56 @@ install_requires = [
 # Testing dependencies
 testing_extras = [
     # Required for running the tests
-    'python-dateutil>=2.7',
-    'pytz>=2014.7',
-    'elasticsearch>=5.0,<6.0',
-    'Jinja2>=2.11,<3.0',
-    'boto3>=1.16,<1.17',
-    'freezegun>=0.3.8',
-    'openpyxl>=2.6.4',
-    'Unidecode>=0.04.14,<2.0',
-    'azure-mgmt-cdn>=5.1,<6.0',
-    'azure-mgmt-frontdoor>=0.3,<0.4',
-
+    "python-dateutil>=2.7",
+    "pytz>=2014.7",
+    "elasticsearch>=5.0,<6.0",
+    "Jinja2>=2.11,<3.0",
+    "boto3>=1.16,<1.17",
+    "freezegun>=0.3.8",
+    "openpyxl>=2.6.4",
+    "Unidecode>=0.04.14,<2.0",
+    "azure-mgmt-cdn>=5.1,<6.0",
+    "azure-mgmt-frontdoor>=0.3,<0.4",
     # For coverage and PEP8 linting
-    'coverage>=3.7.0',
-    'black==22.1.0',
-    'flake8>=3.6.0',
-    'isort==5.6.4',  # leave this pinned - it tends to change rules between patch releases
-    'flake8-blind-except==0.1.1',
-    'flake8-comprehensions==3.8.0',
-    'flake8-print==2.0.2',
-    'doc8==0.8.1',
-    'flake8-assertive==2.0.0',
-
+    "coverage>=3.7.0",
+    "black==22.1.0",
+    "flake8>=3.6.0",
+    "isort==5.6.4",  # leave this pinned - it tends to change rules between patch releases
+    "flake8-blind-except==0.1.1",
+    "flake8-comprehensions==3.8.0",
+    "flake8-print==2.0.2",
+    "doc8==0.8.1",
+    "flake8-assertive==2.0.0",
     # For templates linting
-    'jinjalint>=0.5',
-
+    "jinjalint>=0.5",
     # For template indenting
-    'djhtml==1.4.13',
-
+    "djhtml==1.4.13",
     # Pipenv hack to fix broken dependency causing CircleCI failures
-    'docutils==0.15',
-
+    "docutils==0.15",
     # for validating string formats in .po translation files
-    'polib>=1.1,<2.0',
+    "polib>=1.1,<2.0",
 ]
 
 # Documentation dependencies
 documentation_extras = [
-    'pyenchant>=3.1.1,<4',
-    'sphinxcontrib-spelling>=5.4.0,<6',
-    'Sphinx>=1.5.2',
-    'sphinx-autobuild>=0.6.0',
-    'sphinx-wagtail-theme==5.0.4',
-    'recommonmark>=0.7.1',
+    "pyenchant>=3.1.1,<4",
+    "sphinxcontrib-spelling>=5.4.0,<6",
+    "Sphinx>=1.5.2",
+    "sphinx-autobuild>=0.6.0",
+    "sphinx-wagtail-theme==5.0.4",
+    "recommonmark>=0.7.1",
 ]
 
 setup(
-    name='wagtail',
+    name="wagtail",
     version=__version__,
-    description='A Django content management system.',
-    author='Wagtail core team + contributors',
-    author_email='hello@wagtail.org',  # For support queries, please see https://docs.wagtail.org/en/stable/support.html
-    url='https://wagtail.org/',
+    description="A Django content management system.",
+    author="Wagtail core team + contributors",
+    author_email="hello@wagtail.org",  # For support queries, please see https://docs.wagtail.org/en/stable/support.html
+    url="https://wagtail.org/",
     packages=find_packages(),
     include_package_data=True,
-    license='BSD',
+    license="BSD",
     long_description="Wagtail is an open source content management \
 system built on Django, with a strong community and commercial support. \
 It’s focused on user experience, and offers precise control for \
@@ -104,37 +98,34 @@ designers and developers.\n\n\
 For more details, see https://wagtail.org, https://docs.wagtail.org and \
 https://github.com/wagtail/wagtail/.",
     classifiers=[
-        'Development Status :: 5 - Production/Stable',
-        'Environment :: Web Environment',
-        'Intended Audience :: Developers',
-        'License :: OSI Approved :: BSD License',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.7',
-        'Programming Language :: Python :: 3.8',
-        'Programming Language :: Python :: 3.9',
-        'Programming Language :: Python :: 3.10',
-        'Framework :: Django',
-        'Framework :: Django :: 3.2',
-        'Framework :: Django :: 4.0',
-        'Framework :: Wagtail',
-        'Topic :: Internet :: WWW/HTTP :: Site Management',
+        "Development Status :: 5 - Production/Stable",
+        "Environment :: Web Environment",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: BSD License",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Framework :: Django",
+        "Framework :: Django :: 3.2",
+        "Framework :: Django :: 4.0",
+        "Framework :: Wagtail",
+        "Topic :: Internet :: WWW/HTTP :: Site Management",
     ],
-    python_requires='>=3.7',
+    python_requires=">=3.7",
     install_requires=install_requires,
-    extras_require={
-        'testing': testing_extras,
-        'docs': documentation_extras
-    },
+    extras_require={"testing": testing_extras, "docs": documentation_extras},
     entry_points="""
             [console_scripts]
             wagtail=wagtail.bin.wagtail:main
     """,
     zip_safe=False,
     cmdclass={
-        'sdist': sdist,
-        'bdist_egg': check_bdist_egg,
-        'assets': assets,
+        "sdist": sdist,
+        "bdist_egg": check_bdist_egg,
+        "assets": assets,
     },
 )

+ 1 - 2
wagtail/__init__.py

@@ -4,10 +4,9 @@
 
 from wagtail.utils.version import get_semver_version, get_version
 
-
 # major.minor.patch.release.number
 # release must be one of alpha, beta, rc, or final
-VERSION = (2, 17, 0, 'alpha', 0)
+VERSION = (2, 17, 0, "alpha", 0)
 
 __version__ = get_version(VERSION)
 

+ 1 - 2
wagtail/admin/__init__.py

@@ -1,8 +1,7 @@
 import django
 
-
 if django.VERSION >= (3, 2):
     # The declaration is only needed for older Django versions
     pass
 else:
-    default_app_config = 'wagtail.admin.apps.WagtailAdminAppConfig'
+    default_app_config = "wagtail.admin.apps.WagtailAdminAppConfig"

+ 134 - 112
wagtail/admin/action_menu.py

@@ -13,22 +13,23 @@ from wagtail.core.models import UserPagePermissionsProxy
 
 class ActionMenuItem(Component):
     """Defines an item in the actions drop-up on the page creation/edit view"""
+
     order = 100  # default order index if one is not specified on init
-    template_name = 'wagtailadmin/pages/action_menu/menu_item.html'
+    template_name = "wagtailadmin/pages/action_menu/menu_item.html"
 
-    label = ''
+    label = ""
     name = None
-    classname = ''
-    icon_name = ''
+    classname = ""
+    icon_name = ""
 
     def __init__(self, order=None):
         if order is not None:
             self.order = order
 
     def get_user_page_permissions_tester(self, context):
-        if 'user_page_permissions_tester' in context:
-            return context['user_page_permissions_tester']
-        return context['user_page_permissions'].for_page(context['page'])
+        if "user_page_permissions_tester" in context:
+            return context["user_page_permissions_tester"]
+        return context["user_page_permissions"].for_page(context["page"])
 
     def is_shown(self, context):
         """
@@ -45,7 +46,7 @@ class ActionMenuItem(Component):
             'user_page_permissions_tester' = a PagePermissionTester for the current user and page
         """
         return (
-            context['view'] == 'create'
+            context["view"] == "create"
             or not self.get_user_page_permissions_tester(context).page_locked()
         )
 
@@ -54,14 +55,16 @@ class ActionMenuItem(Component):
         context = parent_context.copy()
         url = self.get_url(parent_context)
 
-        context.update({
-            'label': self.label,
-            'url': url,
-            'name': self.name,
-            'classname': self.classname,
-            'icon_name': self.icon_name,
-            'request': parent_context['request'],
-        })
+        context.update(
+            {
+                "label": self.label,
+                "url": url,
+                "name": self.name,
+                "classname": self.classname,
+                "icon_name": self.icon_name,
+                "request": parent_context["request"],
+            }
+        )
         return context
 
     def get_url(self, parent_context):
@@ -70,99 +73,108 @@ class ActionMenuItem(Component):
 
 class PublishMenuItem(ActionMenuItem):
     label = _("Publish")
-    name = 'action-publish'
-    template_name = 'wagtailadmin/pages/action_menu/publish.html'
-    icon_name = 'upload'
+    name = "action-publish"
+    template_name = "wagtailadmin/pages/action_menu/publish.html"
+    icon_name = "upload"
 
     def is_shown(self, context):
-        if context['view'] == 'create':
-            return context['user_page_permissions'].for_page(context['parent_page']).can_publish_subpage()
-        else:  # view == 'edit' or 'revisions_revert'
-            perms_tester = self.get_user_page_permissions_tester(context)
+        if context["view"] == "create":
             return (
-                not perms_tester.page_locked()
-                and perms_tester.can_publish()
+                context["user_page_permissions"]
+                .for_page(context["parent_page"])
+                .can_publish_subpage()
             )
+        else:  # view == 'edit' or 'revisions_revert'
+            perms_tester = self.get_user_page_permissions_tester(context)
+            return not perms_tester.page_locked() and perms_tester.can_publish()
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
-        context['is_revision'] = (context['view'] == 'revisions_revert')
+        context["is_revision"] = context["view"] == "revisions_revert"
         return context
 
 
 class SubmitForModerationMenuItem(ActionMenuItem):
     label = _("Submit for moderation")
-    name = 'action-submit'
-    icon_name = 'resubmit'
+    name = "action-submit"
+    icon_name = "resubmit"
 
     def is_shown(self, context):
-        if not getattr(settings, 'WAGTAIL_MODERATION_ENABLED', True):
+        if not getattr(settings, "WAGTAIL_MODERATION_ENABLED", True):
             return False
 
-        if context['view'] == 'create':
-            return context['parent_page'].has_workflow
+        if context["view"] == "create":
+            return context["parent_page"].has_workflow
 
-        if context['view'] == 'edit':
+        if context["view"] == "edit":
             perms_tester = self.get_user_page_permissions_tester(context)
-            return perms_tester.can_submit_for_moderation() and not perms_tester.page_locked()
+            return (
+                perms_tester.can_submit_for_moderation()
+                and not perms_tester.page_locked()
+            )
         # context == revisions_revert
         return False
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
-        page = context.get('page')
+        page = context.get("page")
         workflow_state = page.current_workflow_state if page else None
-        if workflow_state and workflow_state.status == workflow_state.STATUS_NEEDS_CHANGES:
-            context['label'] = _("Resubmit to {}").format(workflow_state.current_task_state.task.name)
+        if (
+            workflow_state
+            and workflow_state.status == workflow_state.STATUS_NEEDS_CHANGES
+        ):
+            context["label"] = _("Resubmit to {}").format(
+                workflow_state.current_task_state.task.name
+            )
         elif page:
             workflow = page.get_workflow()
             if workflow:
-                context['label'] = _("Submit to {}").format(workflow.name)
+                context["label"] = _("Submit to {}").format(workflow.name)
         return context
 
 
 class WorkflowMenuItem(ActionMenuItem):
-    template_name = 'wagtailadmin/pages/action_menu/workflow_menu_item.html'
+    template_name = "wagtailadmin/pages/action_menu/workflow_menu_item.html"
 
     def __init__(self, name, label, launch_modal, *args, **kwargs):
         self.name = name
         self.label = label
         self.launch_modal = launch_modal
 
-        if kwargs.get('icon_name'):
-            self.icon_name = kwargs.pop('icon_name')
+        if kwargs.get("icon_name"):
+            self.icon_name = kwargs.pop("icon_name")
 
         super().__init__(*args, **kwargs)
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
-        context['launch_modal'] = self.launch_modal
-        context['current_task_state'] = context['page'].current_workflow_task_state
+        context["launch_modal"] = self.launch_modal
+        context["current_task_state"] = context["page"].current_workflow_task_state
         return context
 
     def is_shown(self, context):
-        if context['view'] == 'edit':
+        if context["view"] == "edit":
             perms_tester = self.get_user_page_permissions_tester(context)
             return not perms_tester.page_locked()
 
 
 class RestartWorkflowMenuItem(ActionMenuItem):
     label = _("Restart workflow ")
-    name = 'action-restart-workflow'
-    classname = 'button--icon-flipped'
-    icon_name = 'login'
+    name = "action-restart-workflow"
+    classname = "button--icon-flipped"
+    icon_name = "login"
 
     def is_shown(self, context):
-        if not getattr(settings, 'WAGTAIL_MODERATION_ENABLED', True):
+        if not getattr(settings, "WAGTAIL_MODERATION_ENABLED", True):
             return False
-        elif context['view'] == 'edit':
-            workflow_state = context['page'].current_workflow_state
+        elif context["view"] == "edit":
+            workflow_state = context["page"].current_workflow_state
             perms_tester = self.get_user_page_permissions_tester(context)
             return (
                 perms_tester.can_submit_for_moderation()
                 and not perms_tester.page_locked()
                 and workflow_state
-                and workflow_state.user_can_cancel(context['request'].user)
+                and workflow_state.user_can_cancel(context["request"].user)
             )
         else:
             return False
@@ -170,118 +182,114 @@ class RestartWorkflowMenuItem(ActionMenuItem):
 
 class CancelWorkflowMenuItem(ActionMenuItem):
     label = _("Cancel workflow ")
-    name = 'action-cancel-workflow'
-    icon_name = 'error'
+    name = "action-cancel-workflow"
+    icon_name = "error"
 
     def is_shown(self, context):
-        if context['view'] == 'edit':
-            workflow_state = context['page'].current_workflow_state
-            return workflow_state and workflow_state.user_can_cancel(context['request'].user)
+        if context["view"] == "edit":
+            workflow_state = context["page"].current_workflow_state
+            return workflow_state and workflow_state.user_can_cancel(
+                context["request"].user
+            )
         return False
 
 
 class UnpublishMenuItem(ActionMenuItem):
     label = _("Unpublish")
-    name = 'action-unpublish'
-    icon_name = 'download-alt'
-    classname = 'action-secondary'
+    name = "action-unpublish"
+    icon_name = "download-alt"
+    classname = "action-secondary"
 
     def is_shown(self, context):
-        if context['view'] == 'edit':
+        if context["view"] == "edit":
             perms_tester = self.get_user_page_permissions_tester(context)
-            return (
-                not perms_tester.page_locked()
-                and perms_tester.can_unpublish()
-            )
+            return not perms_tester.page_locked() and perms_tester.can_unpublish()
 
     def get_url(self, context):
-        return reverse('wagtailadmin_pages:unpublish', args=(context['page'].id,))
+        return reverse("wagtailadmin_pages:unpublish", args=(context["page"].id,))
 
 
 class DeleteMenuItem(ActionMenuItem):
-    name = 'action-delete'
+    name = "action-delete"
     label = _("Delete")
-    icon_name = 'bin'
-    classname = 'action-secondary'
+    icon_name = "bin"
+    classname = "action-secondary"
 
     def is_shown(self, context):
-        if context['view'] == 'edit':
+        if context["view"] == "edit":
             perms_tester = self.get_user_page_permissions_tester(context)
-            return (
-                not perms_tester.page_locked()
-                and perms_tester.can_delete()
-            )
+            return not perms_tester.page_locked() and perms_tester.can_delete()
 
     def get_url(self, context):
-        return reverse('wagtailadmin_pages:delete', args=(context['page'].id,))
+        return reverse("wagtailadmin_pages:delete", args=(context["page"].id,))
 
 
 class LockMenuItem(ActionMenuItem):
-    name = 'action-lock'
+    name = "action-lock"
     label = _("Lock")
     aria_label = _("Apply editor lock")
-    icon_name = 'lock'
-    classname = 'action-secondary'
-    template_name = 'wagtailadmin/pages/action_menu/lock_unlock_menu_item.html'
+    icon_name = "lock"
+    classname = "action-secondary"
+    template_name = "wagtailadmin/pages/action_menu/lock_unlock_menu_item.html"
 
     def is_shown(self, context):
         return (
-            context['view'] == 'edit'
-            and not context['page'].locked
+            context["view"] == "edit"
+            and not context["page"].locked
             and self.get_user_page_permissions_tester(context).can_lock()
         )
 
     def get_url(self, context):
-        return reverse('wagtailadmin_pages:lock', args=(context['page'].id,))
+        return reverse("wagtailadmin_pages:lock", args=(context["page"].id,))
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
-        context['aria_label'] = self.aria_label
+        context["aria_label"] = self.aria_label
         return context
 
 
 class UnlockMenuItem(LockMenuItem):
-    name = 'action-unlock'
+    name = "action-unlock"
     label = _("Unlock")
     aria_label = _("Apply editor lock")
-    icon_name = 'lock-open'
+    icon_name = "lock-open"
 
     def is_shown(self, context):
         return (
-            context['view'] == 'edit'
-            and context['page'].locked
+            context["view"] == "edit"
+            and context["page"].locked
             and self.get_user_page_permissions_tester(context).can_unlock()
         )
 
     def get_url(self, context):
-        return reverse('wagtailadmin_pages:unlock', args=(context['page'].id,))
+        return reverse("wagtailadmin_pages:unlock", args=(context["page"].id,))
 
 
 class SaveDraftMenuItem(ActionMenuItem):
-    name = 'action-save-draft'
+    name = "action-save-draft"
     label = _("Save Draft")
-    template_name = 'wagtailadmin/pages/action_menu/save_draft.html'
+    template_name = "wagtailadmin/pages/action_menu/save_draft.html"
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
-        context['is_revision'] = (context['view'] == 'revisions_revert')
+        context["is_revision"] = context["view"] == "revisions_revert"
         return context
 
 
 class PageLockedMenuItem(ActionMenuItem):
-    name = 'action-page-locked'
+    name = "action-page-locked"
     label = _("Page locked")
-    template_name = 'wagtailadmin/pages/action_menu/page_locked.html'
+    template_name = "wagtailadmin/pages/action_menu/page_locked.html"
 
     def is_shown(self, context):
         return (
-            'page' in context
+            "page" in context
             and self.get_user_page_permissions_tester(context).page_locked()
         )
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
-        context['is_revision'] = (context['view'] == 'revisions_revert')
+        context["is_revision"] = context["view"] == "revisions_revert"
         return context
 
 
@@ -308,7 +316,7 @@ def _get_base_page_action_menu_items():
             SubmitForModerationMenuItem(order=60),
             PageLockedMenuItem(order=10000),
         ]
-        for hook in hooks.get_hooks('register_page_action_menu_item'):
+        for hook in hooks.get_hooks("register_page_action_menu_item"):
             action_menu_item = hook()
             if action_menu_item:
                 BASE_PAGE_ACTION_MENU_ITEMS.append(action_menu_item)
@@ -317,35 +325,45 @@ def _get_base_page_action_menu_items():
 
 
 class PageActionMenu:
-    template = 'wagtailadmin/pages/action_menu/menu.html'
+    template = "wagtailadmin/pages/action_menu/menu.html"
 
     def __init__(self, request, **kwargs):
         self.request = request
         self.context = kwargs
-        self.context['request'] = request
-        page = self.context.get('page')
+        self.context["request"] = request
+        page = self.context.get("page")
         user_page_permissions = UserPagePermissionsProxy(self.request.user)
-        self.context['user_page_permissions'] = user_page_permissions
+        self.context["user_page_permissions"] = user_page_permissions
         if page:
-            self.context['user_page_permissions_tester'] = user_page_permissions.for_page(page)
+            self.context[
+                "user_page_permissions_tester"
+            ] = user_page_permissions.for_page(page)
 
         self.menu_items = []
 
         if page:
             task = page.current_workflow_task
             current_workflow_state = page.current_workflow_state
-            is_final_task = current_workflow_state and current_workflow_state.is_at_final_task
+            is_final_task = (
+                current_workflow_state and current_workflow_state.is_at_final_task
+            )
             if task:
                 actions = task.get_actions(page, request.user)
                 workflow_menu_items = []
                 for name, label, launch_modal in actions:
-                    icon_name = 'edit'
+                    icon_name = "edit"
                     if name == "approve":
-                        if is_final_task and not getattr(settings, 'WAGTAIL_WORKFLOW_REQUIRE_REAPPROVAL_ON_EDIT', False):
-                            label = _("%(label)s and Publish") % {'label': label}
-                        icon_name = 'success'
-
-                    item = WorkflowMenuItem(name, label, launch_modal, icon_name=icon_name)
+                        if is_final_task and not getattr(
+                            settings,
+                            "WAGTAIL_WORKFLOW_REQUIRE_REAPPROVAL_ON_EDIT",
+                            False,
+                        ):
+                            label = _("%(label)s and Publish") % {"label": label}
+                        icon_name = "success"
+
+                    item = WorkflowMenuItem(
+                        name, label, launch_modal, icon_name=icon_name
+                    )
 
                     if item.is_shown(self.context):
                         workflow_menu_items.append(item)
@@ -357,7 +375,7 @@ class PageActionMenu:
 
         self.menu_items.sort(key=lambda item: item.order)
 
-        for hook in hooks.get_hooks('construct_page_action_menu'):
+        for hook in hooks.get_hooks("construct_page_action_menu"):
             hook(self.menu_items, self.request, self.context)
 
         try:
@@ -372,11 +390,15 @@ class PageActionMenu:
 
         rendered_default_item = self.default_item.render_html(self.context)
 
-        return render_to_string(self.template, {
-            'default_menu_item': rendered_default_item,
-            'show_menu': bool(self.menu_items),
-            'rendered_menu_items': rendered_menu_items,
-        }, request=self.request)
+        return render_to_string(
+            self.template,
+            {
+                "default_menu_item": rendered_default_item,
+                "show_menu": bool(self.menu_items),
+                "rendered_menu_items": rendered_menu_items,
+            },
+            request=self.request,
+        )
 
     @cached_property
     def media(self):

+ 13 - 5
wagtail/admin/admin_url_finder.py

@@ -4,7 +4,6 @@ from django.urls import reverse
 
 from wagtail.core.hooks import search_for_hooks
 
-
 """
 A mechanism for finding the admin edit URL for an arbitrary object instance, optionally applying
 permission checks.
@@ -29,6 +28,7 @@ class ModelAdminURLFinder:
     """
     Handles admin edit URL lookups for an individual model
     """
+
     edit_url_name = None
     permission_policy = None
 
@@ -41,8 +41,11 @@ class ModelAdminURLFinder:
         or None if no edit URL is available.
         """
         if self.edit_url_name is None:
-            raise ImproperlyConfigured("%r must define edit_url_name or override construct_edit_url" % type(self))
-        return reverse(self.edit_url_name, args=(quote(instance.pk), ))
+            raise ImproperlyConfigured(
+                "%r must define edit_url_name or override construct_edit_url"
+                % type(self)
+            )
+        return reverse(self.edit_url_name, args=(quote(instance.pk),))
 
     def get_edit_url(self, instance):
         """
@@ -50,8 +53,11 @@ class ModelAdminURLFinder:
         or None otherwise.
         """
         if (
-            self.user and self.permission_policy
-            and not self.permission_policy.user_has_permission_for_instance(self.user, 'change', instance)
+            self.user
+            and self.permission_policy
+            and not self.permission_policy.user_has_permission_for_instance(
+                self.user, "change", instance
+            )
         ):
             return None
         else:
@@ -62,6 +68,7 @@ class NullAdminURLFinder:
     """
     A dummy AdminURLFinder that always returns None
     """
+
     def __init__(self, user=None):
         pass
 
@@ -81,6 +88,7 @@ class AdminURLFinder:
     """
     The 'main' admin URL finder, which searches across all registered models
     """
+
     def __init__(self, user=None):
         search_for_hooks()  # ensure wagtail_hooks files have been loaded
         self.user = user

+ 4 - 1
wagtail/admin/api/actions/convert_alias.py

@@ -5,7 +5,10 @@ from rest_framework.response import Response
 from rest_framework.serializers import Serializer
 
 from wagtail.api.v2.utils import BadRequestError
-from wagtail.core.actions.convert_alias import ConvertAliasPageAction, ConvertAliasPageError
+from wagtail.core.actions.convert_alias import (
+    ConvertAliasPageAction,
+    ConvertAliasPageError,
+)
 
 from .base import APIAction
 

+ 9 - 9
wagtail/admin/api/actions/copy.py

@@ -26,31 +26,31 @@ class CopyPageAPIAction(APIAction):
     serializer = CopyPageAPIActionSerializer
 
     def _action_from_data(self, instance, data):
-        destination_page_id = data.get('destination_page_id')
+        destination_page_id = data.get("destination_page_id")
         if destination_page_id is None:
             destination = instance.get_parent()
         else:
             destination = get_object_or_404(Page, id=destination_page_id)
 
         update_attrs = {}
-        if 'slug' in data:
-            update_attrs['slug'] = data['slug']
+        if "slug" in data:
+            update_attrs["slug"] = data["slug"]
         else:
             # If user didn't specify a particular slug, find an available one
             available_slug = find_available_slug(destination, instance.slug)
             if available_slug != instance.slug:
-                update_attrs['slug'] = available_slug
+                update_attrs["slug"] = available_slug
 
-        if 'title' in data:
-            update_attrs['title'] = data['title']
+        if "title" in data:
+            update_attrs["title"] = data["title"]
 
         return CopyPageAction(
             page=instance,
             to=destination,
-            recursive=data['recursive'],
-            keep_live=data['keep_live'],
+            recursive=data["recursive"],
+            keep_live=data["keep_live"],
             update_attrs=update_attrs,
-            user=self.request.user
+            user=self.request.user,
         )
 
     def execute(self, instance, data):

+ 3 - 1
wagtail/admin/api/actions/copy_for_translation.py

@@ -7,7 +7,9 @@ from rest_framework.serializers import Serializer
 
 from wagtail.api.v2.utils import BadRequestError
 from wagtail.core.actions.copy_for_translation import (
-    CopyPageForTranslationAction, ParentNotTranslatedError)
+    CopyPageForTranslationAction,
+    ParentNotTranslatedError,
+)
 from wagtail.core.models.i18n import Locale
 
 from .base import APIAction

+ 4 - 1
wagtail/admin/api/actions/create_alias.py

@@ -6,7 +6,10 @@ from rest_framework.response import Response
 from rest_framework.serializers import Serializer
 
 from wagtail.api.v2.utils import BadRequestError
-from wagtail.core.actions.create_alias import CreatePageAliasAction, CreatePageAliasIntegrityError
+from wagtail.core.actions.create_alias import (
+    CreatePageAliasAction,
+    CreatePageAliasIntegrityError,
+)
 from wagtail.core.models import Page
 
 from .base import APIAction

+ 12 - 2
wagtail/admin/api/actions/move.py

@@ -15,7 +15,14 @@ class MovePageAPIActionSerializer(Serializer):
     destination_page_id = fields.IntegerField(required=True)
     position = fields.ChoiceField(
         required=False,
-        choices=['left', 'right', 'first-child', 'last-child', 'first-sibling', 'last-sibling']
+        choices=[
+            "left",
+            "right",
+            "first-child",
+            "last-child",
+            "first-sibling",
+            "last-sibling",
+        ],
     )
 
 
@@ -27,7 +34,10 @@ class MovePageAPIAction(APIAction):
         target = get_object_or_404(Page, id=destination_page_id)
 
         return MovePageAction(
-            page=instance, target=target, pos=data.get("position"), user=self.request.user
+            page=instance,
+            target=target,
+            pos=data.get("position"),
+            user=self.request.user,
         )
 
     def execute(self, instance, data):

+ 3 - 1
wagtail/admin/api/actions/revert_to_page_revision.py

@@ -7,7 +7,9 @@ from rest_framework.serializers import Serializer
 
 from wagtail.api.v2.utils import BadRequestError
 from wagtail.core.actions.revert_to_page_revision import (
-    RevertToPageRevisionAction, RevertToPageRevisionError)
+    RevertToPageRevisionAction,
+    RevertToPageRevisionError,
+)
 
 from .base import APIAction
 

+ 9 - 6
wagtail/admin/api/filters.py

@@ -10,10 +10,11 @@ class HasChildrenFilter(BaseFilterBackend):
     Filters the queryset by checking if the pages have children or not.
     This is useful when you want to get just the branches or just the leaves.
     """
+
     def filter_queryset(self, request, queryset, view):
-        if 'has_children' in request.GET:
+        if "has_children" in request.GET:
             try:
-                has_children_filter = parse_boolean(request.GET['has_children'])
+                has_children_filter = parse_boolean(request.GET["has_children"])
             except ValueError:
                 raise BadRequestError("has_children must be 'true' or 'false'")
 
@@ -27,12 +28,14 @@ class HasChildrenFilter(BaseFilterBackend):
 
 class ForExplorerFilter(BaseFilterBackend):
     def filter_queryset(self, request, queryset, view):
-        if request.GET.get('for_explorer'):
-            if not hasattr(queryset, '_filtered_by_child_of'):
-                raise BadRequestError("filtering by for_explorer without child_of is not supported")
+        if request.GET.get("for_explorer"):
+            if not hasattr(queryset, "_filtered_by_child_of"):
+                raise BadRequestError(
+                    "filtering by for_explorer without child_of is not supported"
+                )
 
             parent_page = queryset._filtered_by_child_of
-            for hook in hooks.get_hooks('construct_explorer_page_queryset'):
+            for hook in hooks.get_hooks("construct_explorer_page_queryset"):
                 queryset = hook(parent_page, queryset, request)
 
             user_perms = UserPagePermissionsProxy(request.user)

+ 57 - 18
wagtail/admin/api/serializers.py

@@ -8,10 +8,10 @@ from wagtail.core.models import Page
 
 
 def get_model_listing_url(context, model):
-    url_path = context['router'].get_model_listing_urlpath(model)
+    url_path = context["router"].get_model_listing_urlpath(model)
 
     if url_path:
-        return get_full_url(context['request'], url_path)
+        return get_full_url(context["request"], url_path)
 
 
 class PageStatusField(Field):
@@ -25,15 +25,18 @@ class PageStatusField(Field):
         "has_unpublished_changes": false
     },
     """
+
     def get_attribute(self, instance):
         return instance
 
     def to_representation(self, page):
-        return OrderedDict([
-            ('status', page.status_string),
-            ('live', page.live),
-            ('has_unpublished_changes', page.has_unpublished_changes),
-        ])
+        return OrderedDict(
+            [
+                ("status", page.status_string),
+                ("live", page.live),
+                ("has_unpublished_changes", page.has_unpublished_changes),
+            ]
+        )
 
 
 class PageChildrenField(Field):
@@ -46,14 +49,22 @@ class PageChildrenField(Field):
         "listing_url": "/api/v1/pages/?child_of=2"
     }
     """
+
     def get_attribute(self, instance):
         return instance
 
     def to_representation(self, page):
-        return OrderedDict([
-            ('count', self.context['base_queryset'].child_of(page).count()),
-            ('listing_url', get_model_listing_url(self.context, Page) + '?child_of=' + str(page.id)),
-        ])
+        return OrderedDict(
+            [
+                ("count", self.context["base_queryset"].child_of(page).count()),
+                (
+                    "listing_url",
+                    get_model_listing_url(self.context, Page)
+                    + "?child_of="
+                    + str(page.id),
+                ),
+            ]
+        )
 
 
 class PageDescendantsField(Field):
@@ -66,14 +77,22 @@ class PageDescendantsField(Field):
         "listing_url": "/api/v1/pages/?descendant_of=2"
     }
     """
+
     def get_attribute(self, instance):
         return instance
 
     def to_representation(self, page):
-        return OrderedDict([
-            ('count', self.context['base_queryset'].descendant_of(page).count()),
-            ('listing_url', get_model_listing_url(self.context, Page) + '?descendant_of=' + str(page.id)),
-        ])
+        return OrderedDict(
+            [
+                ("count", self.context["base_queryset"].descendant_of(page).count()),
+                (
+                    "listing_url",
+                    get_model_listing_url(self.context, Page)
+                    + "?descendant_of="
+                    + str(page.id),
+                ),
+            ]
+        )
 
 
 class PageAncestorsField(Field):
@@ -100,11 +119,17 @@ class PageAncestorsField(Field):
         }
     ]
     """
+
     def get_attribute(self, instance):
         return instance
 
     def to_representation(self, page):
-        serializer_class = get_serializer_class(Page, ['id', 'type', 'detail_url', 'html_url', 'title', 'admin_display_title'], meta_fields=['type', 'detail_url', 'html_url'], base=AdminPageSerializer)
+        serializer_class = get_serializer_class(
+            Page,
+            ["id", "type", "detail_url", "html_url", "title", "admin_display_title"],
+            meta_fields=["type", "detail_url", "html_url"],
+            base=AdminPageSerializer,
+        )
         serializer = serializer_class(context=self.context, many=True)
         return serializer.to_representation(page.get_ancestors())
 
@@ -135,11 +160,25 @@ class PageTranslationsField(Field):
         }
     ]
     """
+
     def get_attribute(self, instance):
         return instance
 
     def to_representation(self, page):
-        serializer_class = get_serializer_class(Page, ['id', 'type', 'detail_url', 'html_url', 'locale', 'title', 'admin_display_title'], meta_fields=['type', 'detail_url', 'html_url', 'locale'], base=AdminPageSerializer)
+        serializer_class = get_serializer_class(
+            Page,
+            [
+                "id",
+                "type",
+                "detail_url",
+                "html_url",
+                "locale",
+                "title",
+                "admin_display_title",
+            ],
+            meta_fields=["type", "detail_url", "html_url", "locale"],
+            base=AdminPageSerializer,
+        )
         serializer = serializer_class(context=self.context, many=True)
         return serializer.to_representation(page.get_translations())
 
@@ -150,4 +189,4 @@ class AdminPageSerializer(PageSerializer):
     descendants = PageDescendantsField(read_only=True)
     ancestors = PageAncestorsField(read_only=True)
     translations = PageTranslationsField(read_only=True)
-    admin_display_title = ReadOnlyField(source='get_admin_display_title')
+    admin_display_title = ReadOnlyField(source="get_admin_display_title")

+ 4 - 5
wagtail/admin/api/urls.py

@@ -5,13 +5,12 @@ from wagtail.core import hooks
 
 from .views import PagesAdminAPIViewSet
 
+admin_api = WagtailAPIRouter("wagtailadmin_api")
+admin_api.register_endpoint("pages", PagesAdminAPIViewSet)
 
-admin_api = WagtailAPIRouter('wagtailadmin_api')
-admin_api.register_endpoint('pages', PagesAdminAPIViewSet)
-
-for fn in hooks.get_hooks('construct_admin_api'):
+for fn in hooks.get_hooks("construct_admin_api"):
     fn(admin_api)
 
 urlpatterns = [
-    path('main/', admin_api.urls),
+    path("main/", admin_api.urls),
 ]

+ 43 - 36
wagtail/admin/api/views.py

@@ -27,15 +27,15 @@ class PagesAdminAPIViewSet(PagesAPIViewSet):
     authentication_classes = [SessionAuthentication]
 
     actions = {
-        'convert_alias': ConvertAliasPageAPIAction,
-        'copy': CopyPageAPIAction,
-        'delete': DeletePageAPIAction,
-        'publish': PublishPageAPIAction,
-        'unpublish': UnpublishPageAPIAction,
-        'move': MovePageAPIAction,
-        'copy_for_translation': CopyForTranslationAPIAction,
-        'create_alias': CreatePageAliasAPIAction,
-        'revert_to_page_revision': RevertToPageRevisionAPIAction,
+        "convert_alias": ConvertAliasPageAPIAction,
+        "copy": CopyPageAPIAction,
+        "delete": DeletePageAPIAction,
+        "publish": PublishPageAPIAction,
+        "unpublish": UnpublishPageAPIAction,
+        "move": MovePageAPIAction,
+        "copy_for_translation": CopyForTranslationAPIAction,
+        "create_alias": CreatePageAliasAPIAction,
+        "revert_to_page_revision": RevertToPageRevisionAPIAction,
     }
 
     # Add has_children and for_explorer filters
@@ -45,41 +45,40 @@ class PagesAdminAPIViewSet(PagesAPIViewSet):
     ]
 
     meta_fields = PagesAPIViewSet.meta_fields + [
-        'latest_revision_created_at',
-        'status',
-        'children',
-        'descendants',
-        'parent',
-        'ancestors',
-        'translations',
+        "latest_revision_created_at",
+        "status",
+        "children",
+        "descendants",
+        "parent",
+        "ancestors",
+        "translations",
     ]
 
     body_fields = PagesAPIViewSet.body_fields + [
-        'admin_display_title',
+        "admin_display_title",
     ]
 
     listing_default_fields = PagesAPIViewSet.listing_default_fields + [
-        'latest_revision_created_at',
-        'status',
-        'children',
-        'admin_display_title',
+        "latest_revision_created_at",
+        "status",
+        "children",
+        "admin_display_title",
     ]
 
     # Allow the parent field to appear on listings
     detail_only_fields = []
 
-    known_query_parameters = PagesAPIViewSet.known_query_parameters.union([
-        'for_explorer',
-        'has_children'
-    ])
+    known_query_parameters = PagesAPIViewSet.known_query_parameters.union(
+        ["for_explorer", "has_children"]
+    )
 
     @classmethod
     def get_detail_default_fields(cls, model):
         detail_default_fields = super().get_detail_default_fields(model)
 
         # When i18n is disabled, remove "translations" from default fields
-        if not getattr(settings, 'WAGTAIL_I18N_ENABLED', False):
-            detail_default_fields.remove('translations')
+        if not getattr(settings, "WAGTAIL_I18N_ENABLED", False):
+            detail_default_fields.remove("translations")
 
         return detail_default_fields
 
@@ -111,21 +110,23 @@ class PagesAdminAPIViewSet(PagesAPIViewSet):
         types = OrderedDict()
 
         for name, model in self.seen_types.items():
-            types[name] = OrderedDict([
-                ('verbose_name', model._meta.verbose_name),
-                ('verbose_name_plural', model._meta.verbose_name_plural),
-            ])
+            types[name] = OrderedDict(
+                [
+                    ("verbose_name", model._meta.verbose_name),
+                    ("verbose_name_plural", model._meta.verbose_name_plural),
+                ]
+            )
 
         return types
 
     def listing_view(self, request):
         response = super().listing_view(request)
-        response.data['__types'] = self.get_type_info()
+        response.data["__types"] = self.get_type_info()
         return response
 
     def detail_view(self, request, pk):
         response = super().detail_view(request, pk)
-        response.data['__types'] = self.get_type_info()
+        response.data["__types"] = self.get_type_info()
         return response
 
     def action_view(self, request, pk, action_name):
@@ -148,7 +149,13 @@ class PagesAdminAPIViewSet(PagesAPIViewSet):
         This returns a list of URL patterns for the endpoint
         """
         urlpatterns = super().get_urlpatterns()
-        urlpatterns.extend([
-            path('<int:pk>/action/<str:action_name>/', cls.as_view({'post': 'action_view'}), name='action'),
-        ])
+        urlpatterns.extend(
+            [
+                path(
+                    "<int:pk>/action/<str:action_name>/",
+                    cls.as_view({"post": "action_view"}),
+                    name="action",
+                ),
+            ]
+        )
         return urlpatterns

+ 4 - 3
wagtail/admin/apps.py

@@ -5,11 +5,12 @@ from . import checks  # NOQA
 
 
 class WagtailAdminAppConfig(AppConfig):
-    name = 'wagtail.admin'
-    label = 'wagtailadmin'
+    name = "wagtail.admin"
+    label = "wagtailadmin"
     verbose_name = _("Wagtail admin")
-    default_auto_field = 'django.db.models.AutoField'
+    default_auto_field = "django.db.models.AutoField"
 
     def ready(self):
         from wagtail.admin.signal_handlers import register_signal_handlers
+
         register_signal_handlers()

+ 25 - 15
wagtail/admin/auth.py

@@ -1,9 +1,7 @@
 import types
-
 from functools import wraps
 
 import l18n
-
 from django.contrib.auth import get_user_model
 from django.core.exceptions import PermissionDenied
 from django.db.models import Q
@@ -24,7 +22,9 @@ def users_with_page_permission(page, permission_type, include_superusers=True):
 
     # Find GroupPagePermission records of the given type that apply to this page or an ancestor
     ancestors_and_self = list(page.get_ancestors()) + [page]
-    perm = GroupPagePermission.objects.filter(permission_type=permission_type, page__in=ancestors_and_self)
+    perm = GroupPagePermission.objects.filter(
+        permission_type=permission_type, page__in=ancestors_and_self
+    )
     q = Q(groups__page_permissions__in=perm)
 
     # Include superusers
@@ -36,13 +36,13 @@ def users_with_page_permission(page, permission_type, include_superusers=True):
 
 def permission_denied(request):
     """Return a standard 'permission denied' response"""
-    if request.headers.get('x-requested-with') == 'XMLHttpRequest':
+    if request.headers.get("x-requested-with") == "XMLHttpRequest":
         raise PermissionDenied
 
     from wagtail.admin import messages
 
-    messages.error(request, _('Sorry, you do not have permission to access this area.'))
-    return redirect('wagtailadmin_home')
+    messages.error(request, _("Sorry, you do not have permission to access this area."))
+    return redirect("wagtailadmin_home")
 
 
 def user_passes_test(test):
@@ -50,6 +50,7 @@ def user_passes_test(test):
     Given a test function that takes a user object and returns a boolean,
     return a view decorator that denies access to the user if the test returns false.
     """
+
     def decorator(view_func):
         # decorator takes the view function, and returns the view wrapped in
         # a permission check
@@ -74,6 +75,7 @@ def permission_required(permission_name):
     more meaningful 'permission denied' response than just redirecting to the login page.
     (The latter doesn't work anyway because Wagtail doesn't define LOGIN_URL...)
     """
+
     def test(user):
         return user.has_perm(permission_name)
 
@@ -86,6 +88,7 @@ def any_permission_required(*perms):
     Decorator that accepts a list of permission names, and allows the user
     to pass if they have *any* of the permissions in the list
     """
+
     def test(user):
         for perm in perms:
             if user.has_perm(perm):
@@ -101,6 +104,7 @@ class PermissionPolicyChecker:
     Provides a view decorator that enforces the given permission policy,
     returning the wagtailadmin 'permission denied' response if permission not granted
     """
+
     def __init__(self, policy):
         self.policy = policy
 
@@ -142,14 +146,16 @@ def user_has_any_page_permission(user):
 
 
 def reject_request(request):
-    if request.headers.get('x-requested-with') == 'XMLHttpRequest':
+    if request.headers.get("x-requested-with") == "XMLHttpRequest":
         raise PermissionDenied
 
     # import redirect_to_login here to avoid circular imports on model files that import
     # wagtail.admin.auth, specifically where custom user models are involved
     from django.contrib.auth.views import redirect_to_login as auth_redirect_to_login
+
     return auth_redirect_to_login(
-        request.get_full_path(), login_url=reverse('wagtailadmin_login'))
+        request.get_full_path(), login_url=reverse("wagtailadmin_login")
+    )
 
 
 def require_admin_access(view_func):
@@ -160,11 +166,13 @@ def require_admin_access(view_func):
         if user.is_anonymous:
             return reject_request(request)
 
-        if user.has_perms(['wagtailadmin.access_admin']):
+        if user.has_perms(["wagtailadmin.access_admin"]):
             try:
                 preferred_language = None
-                if hasattr(user, 'wagtail_userprofile'):
-                    preferred_language = user.wagtail_userprofile.get_preferred_language()
+                if hasattr(user, "wagtail_userprofile"):
+                    preferred_language = (
+                        user.wagtail_userprofile.get_preferred_language()
+                    )
                     l18n.set_language(preferred_language)
                     time_zone = user.wagtail_userprofile.get_current_time_zone()
                     activate_tz(time_zone)
@@ -188,20 +196,22 @@ def require_admin_access(view_func):
                                 with override(preferred_language):
                                     return render()
 
-                            response.render = types.MethodType(overridden_render, response)
+                            response.render = types.MethodType(
+                                overridden_render, response
+                            )
                             # decorate the response render method with the override context manager
                         return response
                     else:
                         return view_func(request, *args, **kwargs)
 
             except PermissionDenied:
-                if request.headers.get('x-requested-with') == 'XMLHttpRequest':
+                if request.headers.get("x-requested-with") == "XMLHttpRequest":
                     raise
 
                 return permission_denied(request)
 
-        if not request.headers.get('x-requested-with') == 'XMLHttpRequest':
-            messages.error(request, _('You do not have permission to access the admin'))
+        if not request.headers.get("x-requested-with") == "XMLHttpRequest":
+            messages.error(request, _("You do not have permission to access the admin"))
 
         return reject_request(request)
 

+ 3 - 2
wagtail/admin/blocks.py

@@ -2,5 +2,6 @@ import warnings
 
 from wagtail.core.blocks import *  # noqa
 
-
-warnings.warn("wagtail.admin.blocks has moved to wagtail.core.blocks", UserWarning, stacklevel=2)
+warnings.warn(
+    "wagtail.admin.blocks has moved to wagtail.core.blocks", UserWarning, stacklevel=2
+)

+ 65 - 49
wagtail/admin/checks.py

@@ -4,28 +4,31 @@ from django.core.checks import Error, Tags, Warning, register
 from django.core.exceptions import FieldDoesNotExist
 
 
-@register('staticfiles')
+@register("staticfiles")
 def css_install_check(app_configs, **kwargs):
     errors = []
 
     css_path = os.path.join(
-        os.path.dirname(__file__), 'static', 'wagtailadmin', 'css', 'normalize.css'
+        os.path.dirname(__file__), "static", "wagtailadmin", "css", "normalize.css"
     )
 
     if not os.path.isfile(css_path):
-        error_hint = """
+        error_hint = (
+            """
             Most likely you are running a development (non-packaged) copy of
             Wagtail and have not built the static assets -
             see https://docs.wagtail.org/en/latest/contributing/developing.html
 
             File not found: %s
-        """ % css_path
+        """
+            % css_path
+        )
 
         errors.append(
             Warning(
                 "CSS for the Wagtail admin is missing",
                 hint=error_hint,
-                id='wagtailadmin.W001',
+                id="wagtailadmin.W001",
             )
         )
     return errors
@@ -40,14 +43,18 @@ def base_form_class_check(app_configs, **kwargs):
 
     for cls in get_page_models():
         if not issubclass(cls.base_form_class, WagtailAdminPageForm):
-            errors.append(Error(
-                "{}.base_form_class does not extend WagtailAdminPageForm".format(
-                    cls.__name__),
-                hint="Ensure that {}.{} extends WagtailAdminPageForm".format(
-                    cls.base_form_class.__module__,
-                    cls.base_form_class.__name__),
-                obj=cls,
-                id='wagtailadmin.E001'))
+            errors.append(
+                Error(
+                    "{}.base_form_class does not extend WagtailAdminPageForm".format(
+                        cls.__name__
+                    ),
+                    hint="Ensure that {}.{} extends WagtailAdminPageForm".format(
+                        cls.base_form_class.__module__, cls.base_form_class.__name__
+                    ),
+                    obj=cls,
+                    id="wagtailadmin.E001",
+                )
+            )
 
     return errors
 
@@ -62,18 +69,23 @@ def get_form_class_check(app_configs, **kwargs):
     for cls in get_page_models():
         edit_handler = cls.get_edit_handler()
         if not issubclass(edit_handler.get_form_class(), WagtailAdminPageForm):
-            errors.append(Error(
-                "{cls}.get_edit_handler().get_form_class() does not extend WagtailAdminPageForm".format(
-                    cls=cls.__name__),
-                hint="Ensure that the EditHandler for {cls} creates a subclass of WagtailAdminPageForm".format(
-                    cls=cls.__name__),
-                obj=cls,
-                id='wagtailadmin.E002'))
+            errors.append(
+                Error(
+                    "{cls}.get_edit_handler().get_form_class() does not extend WagtailAdminPageForm".format(
+                        cls=cls.__name__
+                    ),
+                    hint="Ensure that the EditHandler for {cls} creates a subclass of WagtailAdminPageForm".format(
+                        cls=cls.__name__
+                    ),
+                    obj=cls,
+                    id="wagtailadmin.E002",
+                )
+            )
 
     return errors
 
 
-@register('panels')
+@register("panels")
 def inline_panel_model_panels_check(app_configs, **kwargs):
     from wagtail.core.models import get_page_models
 
@@ -91,35 +103,38 @@ def inline_panel_model_panels_check(app_configs, **kwargs):
     return unique_errors
 
 
-def check_panels_in_model(cls, context='model'):
+def check_panels_in_model(cls, context="model"):
     """Check panels configuration uses `panels` when `edit_handler` not in use."""
     from wagtail.admin.edit_handlers import BaseCompositeEditHandler, InlinePanel
     from wagtail.core.models import Page
 
     errors = []
 
-    if hasattr(cls, 'get_edit_handler'):
+    if hasattr(cls, "get_edit_handler"):
         # must check the InlinePanel related models
         edit_handler = cls.get_edit_handler()
         for tab in edit_handler.children:
             if isinstance(tab, BaseCompositeEditHandler):
                 inline_panel_children = [
-                    panel for panel in tab.children if isinstance(panel, InlinePanel)]
+                    panel for panel in tab.children if isinstance(panel, InlinePanel)
+                ]
                 for inline_panel_child in inline_panel_children:
-                    errors.extend(check_panels_in_model(
-                        inline_panel_child.db_field.related_model,
-                        context='InlinePanel model',
-                    ))
-
-    if issubclass(cls, Page) or hasattr(cls, 'edit_handler'):
+                    errors.extend(
+                        check_panels_in_model(
+                            inline_panel_child.db_field.related_model,
+                            context="InlinePanel model",
+                        )
+                    )
+
+    if issubclass(cls, Page) or hasattr(cls, "edit_handler"):
         # Pages do not need to be checked for standalone tabbed_panel usage
         # if edit_handler is used on any model, assume config is correct
         return errors
 
     tabbed_panels = [
-        'content_panels',
-        'promote_panels',
-        'settings_panels',
+        "content_panels",
+        "promote_panels",
+        "settings_panels",
     ]
 
     for panel_name in tabbed_panels:
@@ -127,34 +142,32 @@ def check_panels_in_model(cls, context='model'):
         if not hasattr(cls, panel_name):
             continue
 
-        panel_name_short = panel_name.replace('_panels', '').title()
+        panel_name_short = panel_name.replace("_panels", "").title()
         error_title = "{}.{} will have no effect on {} editing".format(
-            class_name, panel_name, context)
+            class_name, panel_name, context
+        )
 
-        if 'InlinePanel' in context:
+        if "InlinePanel" in context:
             error_hint = """Ensure that {} uses `panels` instead of `{}`.
 There are no tabs on non-Page model editing within InlinePanels.""".format(
-                class_name, panel_name)
+                class_name, panel_name
+            )
         else:
             error_hint = """Ensure that {} uses `panels` instead of `{}`\
 or set up an `edit_handler` if you want a tabbed editing interface.
 There are no default tabs on non-Page models so there will be no \
 {} tab for the {} to render in.""".format(
-                class_name, panel_name, panel_name_short, panel_name)
+                class_name, panel_name, panel_name_short, panel_name
+            )
 
-        error = Warning(
-            error_title,
-            hint=error_hint,
-            obj=cls,
-            id='wagtailadmin.W002'
-        )
+        error = Warning(error_title, hint=error_hint, obj=cls, id="wagtailadmin.W002")
 
         errors.append(error)
 
     return errors
 
 
-@register('panels')
+@register("panels")
 def panel_type_check(app_configs, **kwargs):
     from wagtail.core.models import get_page_models
 
@@ -185,16 +198,19 @@ def check_stream_field_panel_type(edit_handler):
     from wagtail.core.fields import StreamField
 
     try:
-        db_field = getattr(edit_handler, 'db_field', None)
-        if isinstance(db_field, StreamField) and not isinstance(edit_handler, StreamFieldPanel):
+        db_field = getattr(edit_handler, "db_field", None)
+        if isinstance(db_field, StreamField) and not isinstance(
+            edit_handler, StreamFieldPanel
+        ):
             return Warning(
                 "{model}.{field_name} is a StreamField, but uses {edit_handler}".format(
                     model=edit_handler.model.__name__,
                     field_name=edit_handler.field_name,
-                    edit_handler=edit_handler.__class__.__name__),
+                    edit_handler=edit_handler.__class__.__name__,
+                ),
                 hint="Ensure that it uses a StreamFieldPanel, or change the field type",
                 obj=edit_handler.model,
-                id='wagtailadmin.W003'
+                id="wagtailadmin.W003",
             )
     except FieldDoesNotExist:
         # Doesn't check any fields not on the model, such as in

+ 135 - 82
wagtail/admin/compare.py

@@ -12,7 +12,7 @@ from wagtail.core import blocks
 
 def text_from_html(val):
     # Return the unescaped text content of an HTML string
-    return BeautifulSoup(force_str(val), 'html5lib').getText()
+    return BeautifulSoup(force_str(val), "html5lib").getText()
 
 
 class FieldComparison:
@@ -28,17 +28,19 @@ class FieldComparison:
         """
         Returns a label for this field to be displayed to the user
         """
-        verbose_name = getattr(self.field, 'verbose_name', None)
+        verbose_name = getattr(self.field, "verbose_name", None)
 
         if verbose_name is None:
             # Relations don't have a verbose_name
-            verbose_name = self.field.name.replace('_', ' ')
+            verbose_name = self.field.name.replace("_", " ")
 
         return capfirst(verbose_name)
 
     def htmldiff(self):
         if self.val_a != self.val_b:
-            return TextDiff([('deletion', self.val_a), ('addition', self.val_b)]).to_html()
+            return TextDiff(
+                [("deletion", self.val_a), ("addition", self.val_b)]
+            ).to_html()
         else:
             return escape(self.val_a)
 
@@ -57,13 +59,12 @@ class TextFieldComparison(FieldComparison):
 class RichTextFieldComparison(TextFieldComparison):
     def htmldiff(self):
         return diff_text(
-            text_from_html(self.val_a),
-            text_from_html(self.val_b)
+            text_from_html(self.val_a), text_from_html(self.val_b)
         ).to_html()
 
 
 def get_comparison_class_for_block(block):
-    if hasattr(block, 'get_comparison_class'):
+    if hasattr(block, "get_comparison_class"):
         return block.get_comparison_class()
     elif isinstance(block, (blocks.CharBlock, blocks.TextBlock)):
         return CharBlockComparison
@@ -109,17 +110,13 @@ class BlockComparison:
         html_val_a = self.block.render_basic(self.val_a)
         html_val_b = self.block.render_basic(self.val_b)
         return diff_text(
-            text_from_html(html_val_a),
-            text_from_html(html_val_b)
+            text_from_html(html_val_a), text_from_html(html_val_b)
         ).to_html()
 
 
 class CharBlockComparison(BlockComparison):
     def htmldiff(self):
-        return diff_text(
-            force_str(self.val_a),
-            force_str(self.val_b)
-        ).to_html()
+        return diff_text(force_str(self.val_a), force_str(self.val_b)).to_html()
 
     def htmlvalue(self, val):
         return escape(val)
@@ -136,10 +133,19 @@ class StructBlockComparison(BlockComparison):
             label = self.block.child_blocks[name].label
             comparison_class = get_comparison_class_for_block(block)
 
-            htmlvalues.append((label, comparison_class(block, True, True, val[name], val[name]).htmlvalue(val[name])))
+            htmlvalues.append(
+                (
+                    label,
+                    comparison_class(block, True, True, val[name], val[name]).htmlvalue(
+                        val[name]
+                    ),
+                )
+            )
 
-        return format_html('<dl>\n{}\n</dl>', format_html_join(
-            '\n', '    <dt>{}</dt>\n    <dd>{}</dd>', htmlvalues))
+        return format_html(
+            "<dl>\n{}\n</dl>",
+            format_html_join("\n", "    <dt>{}</dt>\n    <dd>{}</dd>", htmlvalues),
+        )
 
     def htmldiff(self):
         htmldiffs = []
@@ -147,10 +153,23 @@ class StructBlockComparison(BlockComparison):
             label = self.block.child_blocks[name].label
             comparison_class = get_comparison_class_for_block(block)
 
-            htmldiffs.append((label, comparison_class(block, self.exists_a, self.exists_b, self.val_a[name], self.val_b[name]).htmldiff()))
-
-        return format_html('<dl>\n{}\n</dl>', format_html_join(
-            '\n', '    <dt>{}</dt>\n    <dd>{}</dd>', htmldiffs))
+            htmldiffs.append(
+                (
+                    label,
+                    comparison_class(
+                        block,
+                        self.exists_a,
+                        self.exists_b,
+                        self.val_a[name],
+                        self.val_b[name],
+                    ).htmldiff(),
+                )
+            )
+
+        return format_html(
+            "<dl>\n{}\n</dl>",
+            format_html_join("\n", "    <dt>{}</dt>\n    <dd>{}</dd>", htmldiffs),
+        )
 
 
 class StreamBlockComparison(BlockComparison):
@@ -169,17 +188,31 @@ class StreamBlockComparison(BlockComparison):
 
             if block.id in a_blocks_by_id:
                 # Changed/existing block
-                comparisons.append(comparison_class(block.block, True, True, a_blocks_by_id[block.id].value, block.value))
+                comparisons.append(
+                    comparison_class(
+                        block.block,
+                        True,
+                        True,
+                        a_blocks_by_id[block.id].value,
+                        block.value,
+                    )
+                )
             else:
                 # New block
-                comparisons.append(comparison_class(block.block, False, True, None, block.value))
+                comparisons.append(
+                    comparison_class(block.block, False, True, None, block.value)
+                )
 
         # Insert deleted blocks at the index where they used to be
-        deleted_block_indices = [(block, i) for i, block in enumerate(a_blocks) if block.id in deleted_ids]
+        deleted_block_indices = [
+            (block, i) for i, block in enumerate(a_blocks) if block.id in deleted_ids
+        ]
 
         for block, index in deleted_block_indices:
             comparison_class = get_comparison_class_for_block(block.block)
-            comparison_to_insert = comparison_class(block.block, True, False, block.value, None)
+            comparison_to_insert = comparison_class(
+                block.block, True, False, block.value, None
+            )
 
             # Insert the block back in where it was before it was deleted.
             # Note: we need to account for new blocks when finding the position.
@@ -206,22 +239,24 @@ class StreamBlockComparison(BlockComparison):
         comparisons_html = []
 
         for comparison in self.get_block_comparisons():
-            classes = ['comparison__child-object']
+            classes = ["comparison__child-object"]
             if comparison.is_new():
-                classes.append('addition')
+                classes.append("addition")
                 block_rendered = comparison.htmlvalue(comparison.val_b)
             elif comparison.is_deleted():
-                classes.append('deletion')
+                classes.append("deletion")
                 block_rendered = comparison.htmlvalue(comparison.val_a)
             elif comparison.has_changed():
                 block_rendered = comparison.htmldiff()
             else:
                 block_rendered = comparison.htmlvalue(comparison.val_a)
 
-            classes = ' '.join(classes)
-            comparisons_html.append('<div class="{0}">{1}</div>'.format(classes, block_rendered))
+            classes = " ".join(classes)
+            comparisons_html.append(
+                '<div class="{0}">{1}</div>'.format(classes, block_rendered)
+            )
 
-        return mark_safe('\n'.join(comparisons_html))
+        return mark_safe("\n".join(comparisons_html))
 
 
 class StreamFieldComparison(FieldComparison):
@@ -236,27 +271,32 @@ class StreamFieldComparison(FieldComparison):
         # But as UUIDs were added in Wagtail 1.11 we can't compare revisions that were created before
         # that Wagtail version.
         if self.has_block_ids(self.val_a) and self.has_block_ids(self.val_b):
-            return StreamBlockComparison(self.field.stream_block, True, True, self.val_a, self.val_b).htmldiff()
+            return StreamBlockComparison(
+                self.field.stream_block, True, True, self.val_a, self.val_b
+            ).htmldiff()
         else:
             # Fall back to diffing the HTML representation
             return diff_text(
-                text_from_html(self.val_a),
-                text_from_html(self.val_b)
+                text_from_html(self.val_a), text_from_html(self.val_b)
             ).to_html()
 
 
 class ChoiceFieldComparison(FieldComparison):
     def htmldiff(self):
-        val_a = force_str(dict(self.field.flatchoices).get(self.val_a, self.val_a), strings_only=True)
-        val_b = force_str(dict(self.field.flatchoices).get(self.val_b, self.val_b), strings_only=True)
+        val_a = force_str(
+            dict(self.field.flatchoices).get(self.val_a, self.val_a), strings_only=True
+        )
+        val_b = force_str(
+            dict(self.field.flatchoices).get(self.val_b, self.val_b), strings_only=True
+        )
 
         if self.val_a != self.val_b:
             diffs = []
 
             if val_a:
-                diffs += [('deletion', val_a)]
+                diffs += [("deletion", val_a)]
             if val_b:
-                diffs += [('addition', val_b)]
+                diffs += [("addition", val_b)]
 
             return TextDiff(diffs).to_html()
         else:
@@ -278,20 +318,20 @@ class M2MFieldComparison(FieldComparison):
         sm = difflib.SequenceMatcher(0, items_a, items_b)
         changes = []
         for op, i1, i2, j1, j2 in sm.get_opcodes():
-            if op == 'replace':
+            if op == "replace":
                 for item in items_a[i1:i2]:
-                    changes.append(('deletion', self.get_item_display(item)))
+                    changes.append(("deletion", self.get_item_display(item)))
                 for item in items_b[j1:j2]:
-                    changes.append(('addition', self.get_item_display(item)))
-            elif op == 'delete':
+                    changes.append(("addition", self.get_item_display(item)))
+            elif op == "delete":
                 for item in items_a[i1:i2]:
-                    changes.append(('deletion', self.get_item_display(item)))
-            elif op == 'insert':
+                    changes.append(("deletion", self.get_item_display(item)))
+            elif op == "insert":
                 for item in items_b[j1:j2]:
-                    changes.append(('addition', self.get_item_display(item)))
-            elif op == 'equal':
+                    changes.append(("addition", self.get_item_display(item)))
+            elif op == "equal":
                 for item in items_a[i1:i2]:
-                    changes.append(('equal', self.get_item_display(item)))
+                    changes.append(("equal", self.get_item_display(item)))
 
         # Convert changelist to HTML
         return TextDiff(changes, separator=", ").to_html()
@@ -319,13 +359,15 @@ class ForeignObjectComparison(FieldComparison):
         if obj_a != obj_b:
             if obj_a and obj_b:
                 # Changed
-                return TextDiff([('deletion', force_str(obj_a)), ('addition', force_str(obj_b))]).to_html()
+                return TextDiff(
+                    [("deletion", force_str(obj_a)), ("addition", force_str(obj_b))]
+                ).to_html()
             elif obj_b:
                 # Added
-                return TextDiff([('addition', force_str(obj_b))]).to_html()
+                return TextDiff([("addition", force_str(obj_b))]).to_html()
             elif obj_a:
                 # Removed
-                return TextDiff([('deletion', force_str(obj_a))]).to_html()
+                return TextDiff([("deletion", force_str(obj_a))]).to_html()
         else:
             if obj_a:
                 return escape(force_str(obj_a))
@@ -347,11 +389,11 @@ class ChildRelationComparison:
         """
         Returns a label for this field to be displayed to the user
         """
-        verbose_name = getattr(self.field, 'verbose_name', None)
+        verbose_name = getattr(self.field, "verbose_name", None)
 
         if verbose_name is None:
             # Relations don't have a verbose_name
-            verbose_name = self.field.name.replace('_', ' ')
+            verbose_name = self.field.name.replace("_", " ")
 
         return capfirst(verbose_name)
 
@@ -404,7 +446,11 @@ class ChildRelationComparison:
                 if b_idx in map_backwards:
                     continue
 
-                if a_child.pk is not None and b_child.pk is not None and a_child.pk == b_child.pk:
+                if (
+                    a_child.pk is not None
+                    and b_child.pk is not None
+                    and a_child.pk == b_child.pk
+                ):
                     map_forwards[a_idx] = b_idx
                     map_backwards[b_idx] = a_idx
 
@@ -418,7 +464,9 @@ class ChildRelationComparison:
                         if a_child.pk and b_child.pk and a_child.pk != b_child.pk:
                             continue
 
-                        comparison = self.get_child_comparison(objs_a[a_idx], objs_b[b_idx])
+                        comparison = self.get_child_comparison(
+                            objs_a[a_idx], objs_b[b_idx]
+                        )
                         num_differences = comparison.get_num_differences()
 
                         matches.append((a_idx, b_idx, num_differences))
@@ -446,7 +494,9 @@ class ChildRelationComparison:
         return map_forwards, map_backwards, added, deleted
 
     def get_child_comparison(self, obj_a, obj_b):
-        return ChildObjectComparison(self.field.related_model, self.field_comparisons, obj_a, obj_b)
+        return ChildObjectComparison(
+            self.field.related_model, self.field_comparisons, obj_a, obj_b
+        )
 
     def get_child_comparisons(self):
         """
@@ -471,7 +521,9 @@ class ChildRelationComparison:
             if b_idx in added:
                 comparisons.append(self.get_child_comparison(None, b_child))
             else:
-                comparisons.append(self.get_child_comparison(objs_a[map_backwards[b_idx]], b_child))
+                comparisons.append(
+                    self.get_child_comparison(objs_a[map_backwards[b_idx]], b_child)
+                )
 
         for a_idx, a_child in objs_a.items():
             if a_idx in deleted:
@@ -529,8 +581,8 @@ class ChildObjectComparison:
         indicates the object moved up one space.
         """
         if not self.is_addition() and not self.is_deletion():
-            sort_a = getattr(self.obj_a, 'sort_order', 0) or 0
-            sort_b = getattr(self.obj_b, 'sort_order', 0) or 0
+            sort_a = getattr(self.obj_a, "sort_order", 0) or 0
+            sort_b = getattr(self.obj_b, "sort_order", 0) or 0
             return sort_b - sort_a
 
     def get_field_comparisons(self):
@@ -578,24 +630,24 @@ class TextDiff:
         self.changes = changes
         self.separator = separator
 
-    def to_html(self, tag='span', addition_class='addition', deletion_class='deletion'):
+    def to_html(self, tag="span", addition_class="addition", deletion_class="deletion"):
         html = []
 
         for change_type, value in self.changes:
-            if change_type == 'equal':
+            if change_type == "equal":
                 html.append(escape(value))
-            elif change_type == 'addition':
-                html.append('<{tag} class="{classname}">{value}</{tag}>'.format(
-                    tag=tag,
-                    classname=addition_class,
-                    value=escape(value)
-                ))
-            elif change_type == 'deletion':
-                html.append('<{tag} class="{classname}">{value}</{tag}>'.format(
-                    tag=tag,
-                    classname=deletion_class,
-                    value=escape(value)
-                ))
+            elif change_type == "addition":
+                html.append(
+                    '<{tag} class="{classname}">{value}</{tag}>'.format(
+                        tag=tag, classname=addition_class, value=escape(value)
+                    )
+                )
+            elif change_type == "deletion":
+                html.append(
+                    '<{tag} class="{classname}">{value}</{tag}>'.format(
+                        tag=tag, classname=deletion_class, value=escape(value)
+                    )
+                )
 
         return mark_safe(self.separator.join(html))
 
@@ -606,6 +658,7 @@ def diff_text(a, b):
     a string of HTML containing the content of both texts with
     <span> tags inserted indicating where the differences are.
     """
+
     def tokenise(text):
         """
         Tokenises a string by splitting it into individual characters
@@ -643,20 +696,20 @@ def diff_text(a, b):
     changes = []
 
     for op, i1, i2, j1, j2 in sm.get_opcodes():
-        if op == 'replace':
+        if op == "replace":
             for token in a_tok[i1:i2]:
-                changes.append(('deletion', token))
+                changes.append(("deletion", token))
             for token in b_tok[j1:j2]:
-                changes.append(('addition', token))
-        elif op == 'delete':
+                changes.append(("addition", token))
+        elif op == "delete":
             for token in a_tok[i1:i2]:
-                changes.append(('deletion', token))
-        elif op == 'insert':
+                changes.append(("deletion", token))
+        elif op == "insert":
             for token in b_tok[j1:j2]:
-                changes.append(('addition', token))
-        elif op == 'equal':
+                changes.append(("addition", token))
+        elif op == "equal":
             for token in a_tok[i1:i2]:
-                changes.append(('equal', token))
+                changes.append(("equal", token))
 
     # Merge adjacent changes which have the same type. This just cleans up the HTML a bit
     merged_changes = []
@@ -665,7 +718,7 @@ def diff_text(a, b):
     for change_type, value in changes:
         if change_type != current_change_type:
             if current_change_type is not None:
-                merged_changes.append((current_change_type, ''.join(current_value)))
+                merged_changes.append((current_change_type, "".join(current_value)))
                 current_value = []
 
             current_change_type = change_type
@@ -673,6 +726,6 @@ def diff_text(a, b):
         current_value.append(value)
 
     if current_value:
-        merged_changes.append((current_change_type, ''.join(current_value)))
+        merged_changes.append((current_change_type, "".join(current_value)))
 
     return TextDiff(merged_changes)

+ 256 - 178
wagtail/admin/edit_handlers.py

@@ -30,41 +30,48 @@ from wagtail.utils.decorators import cached_classmethod
 # compatibility, as people are likely importing them from here and then
 # appending their own overrides
 from .forms.models import (  # NOQA
-    DIRECT_FORM_FIELD_OVERRIDES, FORM_FIELD_OVERRIDES, WagtailAdminModelForm, formfield_for_dbfield)
+    DIRECT_FORM_FIELD_OVERRIDES,
+    FORM_FIELD_OVERRIDES,
+    WagtailAdminModelForm,
+    formfield_for_dbfield,
+)
 from .forms.pages import WagtailAdminPageForm
 
 
 def widget_with_script(widget, script):
-    return mark_safe('{0}<script>{1}</script>'.format(widget, script))
+    return mark_safe("{0}<script>{1}</script>".format(widget, script))
 
 
 def get_form_for_model(
-    model, form_class=WagtailAdminModelForm,
-    fields=None, exclude=None, formsets=None, exclude_formsets=None, widgets=None
+    model,
+    form_class=WagtailAdminModelForm,
+    fields=None,
+    exclude=None,
+    formsets=None,
+    exclude_formsets=None,
+    widgets=None,
 ):
 
     # django's modelform_factory with a bit of custom behaviour
-    attrs = {'model': model}
+    attrs = {"model": model}
     if fields is not None:
-        attrs['fields'] = fields
+        attrs["fields"] = fields
     if exclude is not None:
-        attrs['exclude'] = exclude
+        attrs["exclude"] = exclude
     if widgets is not None:
-        attrs['widgets'] = widgets
+        attrs["widgets"] = widgets
     if formsets is not None:
-        attrs['formsets'] = formsets
+        attrs["formsets"] = formsets
     if exclude_formsets is not None:
-        attrs['exclude_formsets'] = exclude_formsets
+        attrs["exclude_formsets"] = exclude_formsets
 
     # Give this new form class a reasonable name.
-    class_name = model.__name__ + str('Form')
+    class_name = model.__name__ + str("Form")
     bases = (object,)
-    if hasattr(form_class, 'Meta'):
+    if hasattr(form_class, "Meta"):
         bases = (form_class.Meta,) + bases
 
-    form_class_attrs = {
-        'Meta': type(str('Meta'), bases, attrs)
-    }
+    form_class_attrs = {"Meta": type(str("Meta"), bases, attrs)}
 
     metaclass = type(form_class)
 
@@ -72,7 +79,7 @@ def get_form_for_model(
 
 
 def extract_panel_definitions_from_model_class(model, exclude=None):
-    if hasattr(model, 'panels'):
+    if hasattr(model, "panels"):
         return model.panels
 
     panels = []
@@ -81,7 +88,9 @@ def extract_panel_definitions_from_model_class(model, exclude=None):
     if exclude:
         _exclude.extend(exclude)
 
-    fields = fields_for_model(model, exclude=_exclude, formfield_callback=formfield_for_dbfield)
+    fields = fields_for_model(
+        model, exclude=_exclude, formfield_callback=formfield_for_dbfield
+    )
 
     for field_name, field in fields.items():
         try:
@@ -101,7 +110,7 @@ class EditHandler:
     the EditHandler API
     """
 
-    def __init__(self, heading='', classname='', help_text=''):
+    def __init__(self, heading="", classname="", help_text=""):
         self.heading = heading
         self.classname = classname
         self.help_text = help_text
@@ -115,9 +124,9 @@ class EditHandler:
 
     def clone_kwargs(self):
         return {
-            'heading': self.heading,
-            'classname': self.classname,
-            'help_text': self.help_text,
+            "heading": self.heading,
+            "classname": self.classname,
+            "help_text": self.help_text,
         }
 
     # return list of widget overrides that this EditHandler wants to be in place
@@ -139,7 +148,7 @@ class EditHandler:
     # Typically this will be used to define snippets of HTML within <script type="text/x-template"></script> blocks
     # for JavaScript code to work with.
     def html_declarations(self):
-        return ''
+        return ""
 
     def bind_to(self, model=None, instance=None, request=None, form=None):
         if model is None and instance is not None and self.model is None:
@@ -178,9 +187,13 @@ class EditHandler:
         pass
 
     def __repr__(self):
-        return '<%s with model=%s instance=%s request=%s form=%s>' % (
+        return "<%s with model=%s instance=%s request=%s form=%s>" % (
             self.__class__.__name__,
-            self.model, self.instance, self.request, self.form.__class__.__name__)
+            self.model,
+            self.instance,
+            self.request,
+            self.form.__class__.__name__,
+        )
 
     def classes(self):
         """
@@ -237,7 +250,7 @@ class EditHandler:
             if field_name not in rendered_fields
         ]
 
-        return mark_safe(''.join(missing_fields_html))
+        return mark_safe("".join(missing_fields_html))
 
     def render_form_content(self):
         """
@@ -262,7 +275,7 @@ class BaseCompositeEditHandler(EditHandler):
 
     def clone_kwargs(self):
         kwargs = super().clone_kwargs()
-        kwargs['children'] = self.children
+        kwargs["children"] = self.children
         return kwargs
 
     def widget_overrides(self):
@@ -287,19 +300,18 @@ class BaseCompositeEditHandler(EditHandler):
         return formsets
 
     def html_declarations(self):
-        return mark_safe(''.join([c.html_declarations() for c in self.children]))
+        return mark_safe("".join([c.html_declarations() for c in self.children]))
 
     def on_model_bound(self):
-        self.children = [child.bind_to(model=self.model)
-                         for child in self.children]
+        self.children = [child.bind_to(model=self.model) for child in self.children]
 
     def on_instance_bound(self):
-        self.children = [child.bind_to(instance=self.instance)
-                         for child in self.children]
+        self.children = [
+            child.bind_to(instance=self.instance) for child in self.children
+        ]
 
     def on_request_bound(self):
-        self.children = [child.bind_to(request=self.request)
-                         for child in self.children]
+        self.children = [child.bind_to(request=self.request) for child in self.children]
 
     def on_form_bound(self):
         children = []
@@ -315,9 +327,7 @@ class BaseCompositeEditHandler(EditHandler):
         self.children = children
 
     def render(self):
-        return mark_safe(render_to_string(self.template, {
-            'self': self
-        }))
+        return mark_safe(render_to_string(self.template, {"self": self}))
 
     def get_comparison(self):
         comparators = []
@@ -347,13 +357,13 @@ class BaseFormEditHandler(BaseCompositeEditHandler):
         """
         if self.model is None:
             raise AttributeError(
-                '%s is not bound to a model yet. Use `.bind_to(model=model)` '
-                'before using this method.' % self.__class__.__name__)
+                "%s is not bound to a model yet. Use `.bind_to(model=model)` "
+                "before using this method." % self.__class__.__name__
+            )
         # If a custom form class was passed to the EditHandler, use it.
         # Otherwise, use the base_form_class from the model.
         # If that is not defined, use WagtailAdminModelForm.
-        model_form_class = getattr(self.model, 'base_form_class',
-                                   WagtailAdminModelForm)
+        model_form_class = getattr(self.model, "base_form_class", WagtailAdminModelForm)
         base_form_class = self.base_form_class or model_form_class
 
         return get_form_for_model(
@@ -361,19 +371,22 @@ class BaseFormEditHandler(BaseCompositeEditHandler):
             form_class=base_form_class,
             fields=self.required_fields(),
             formsets=self.required_formsets(),
-            widgets=self.widget_overrides())
+            widgets=self.widget_overrides(),
+        )
 
 
 class TabbedInterface(BaseFormEditHandler):
     template = "wagtailadmin/edit_handlers/tabbed_interface.html"
 
     def __init__(self, *args, show_comments_toggle=None, **kwargs):
-        self.base_form_class = kwargs.pop('base_form_class', None)
+        self.base_form_class = kwargs.pop("base_form_class", None)
         super().__init__(*args, **kwargs)
         if show_comments_toggle is not None:
             self.show_comments_toggle = show_comments_toggle
         else:
-            self.show_comments_toggle = 'comment_notifications' in self.required_fields()
+            self.show_comments_toggle = (
+                "comment_notifications" in self.required_fields()
+            )
 
     def get_form_class(self):
         form_class = super().get_form_class()
@@ -381,16 +394,14 @@ class TabbedInterface(BaseFormEditHandler):
         # Set show_comments_toggle attribute on form class
         return type(
             form_class.__name__,
-            (form_class, ),
-            {
-                'show_comments_toggle': self.show_comments_toggle
-            }
+            (form_class,),
+            {"show_comments_toggle": self.show_comments_toggle},
         )
 
     def clone_kwargs(self):
         kwargs = super().clone_kwargs()
-        kwargs['base_form_class'] = self.base_form_class
-        kwargs['show_comments_toggle'] = self.show_comments_toggle
+        kwargs["base_form_class"] = self.base_form_class
+        kwargs["show_comments_toggle"] = self.show_comments_toggle
         return kwargs
 
 
@@ -404,11 +415,11 @@ class FieldRowPanel(BaseCompositeEditHandler):
     def on_instance_bound(self):
         super().on_instance_bound()
 
-        col_count = ' col%s' % (12 // len(self.children))
+        col_count = " col%s" % (12 // len(self.children))
         # If child panel doesn't have a col# class then append default based on
         # number of columns
         for child in self.children:
-            if not re.search(r'\bcol\d+\b', child.classname):
+            if not re.search(r"\bcol\d+\b", child.classname):
                 child.classname += col_count
 
 
@@ -422,15 +433,20 @@ class MultiFieldPanel(BaseCompositeEditHandler):
 
 
 class HelpPanel(EditHandler):
-    def __init__(self, content='', template='wagtailadmin/edit_handlers/help_panel.html',
-                 heading='', classname=''):
+    def __init__(
+        self,
+        content="",
+        template="wagtailadmin/edit_handlers/help_panel.html",
+        heading="",
+        classname="",
+    ):
         super().__init__(heading=heading, classname=classname)
         self.content = content
         self.template = template
 
     def clone_kwargs(self):
         kwargs = super().clone_kwargs()
-        del kwargs['help_text']
+        del kwargs["help_text"]
         kwargs.update(
             content=self.content,
             template=self.template,
@@ -438,19 +454,17 @@ class HelpPanel(EditHandler):
         return kwargs
 
     def render(self):
-        return mark_safe(render_to_string(self.template, {
-            'self': self
-        }))
+        return mark_safe(render_to_string(self.template, {"self": self}))
 
 
 class FieldPanel(EditHandler):
-    TEMPLATE_VAR = 'field_panel'
+    TEMPLATE_VAR = "field_panel"
 
     def __init__(self, field_name, *args, **kwargs):
-        widget = kwargs.pop('widget', None)
+        widget = kwargs.pop("widget", None)
         if widget is not None:
             self.widget = widget
-        self.comments_enabled = not kwargs.pop('disable_comments', False)
+        self.comments_enabled = not kwargs.pop("disable_comments", False)
         super().__init__(*args, **kwargs)
         self.field_name = field_name
 
@@ -458,13 +472,13 @@ class FieldPanel(EditHandler):
         kwargs = super().clone_kwargs()
         kwargs.update(
             field_name=self.field_name,
-            widget=self.widget if hasattr(self, 'widget') else None,
+            widget=self.widget if hasattr(self, "widget") else None,
         )
         return kwargs
 
     def widget_overrides(self):
         """check if a specific widget has been defined for this field"""
-        if hasattr(self, 'widget'):
+        if hasattr(self, "widget"):
             return {self.field_name: self.widget}
         return {}
 
@@ -489,21 +503,37 @@ class FieldPanel(EditHandler):
     object_template = "wagtailadmin/edit_handlers/single_field_panel.html"
 
     def render_as_object(self):
-        return mark_safe(render_to_string(self.object_template, {
-            'self': self,
-            self.TEMPLATE_VAR: self,
-            'field': self.bound_field,
-            'show_add_comment_button': self.comments_enabled and getattr(self.bound_field.field.widget, 'show_add_comment_button', True),
-        }))
+        return mark_safe(
+            render_to_string(
+                self.object_template,
+                {
+                    "self": self,
+                    self.TEMPLATE_VAR: self,
+                    "field": self.bound_field,
+                    "show_add_comment_button": self.comments_enabled
+                    and getattr(
+                        self.bound_field.field.widget, "show_add_comment_button", True
+                    ),
+                },
+            )
+        )
 
     field_template = "wagtailadmin/edit_handlers/field_panel_field.html"
 
     def render_as_field(self):
-        return mark_safe(render_to_string(self.field_template, {
-            'field': self.bound_field,
-            'field_type': self.field_type(),
-            'show_add_comment_button': self.comments_enabled and getattr(self.bound_field.field.widget, 'show_add_comment_button', True),
-        }))
+        return mark_safe(
+            render_to_string(
+                self.field_template,
+                {
+                    "field": self.bound_field,
+                    "field_type": self.field_type(),
+                    "show_add_comment_button": self.comments_enabled
+                    and getattr(
+                        self.bound_field.field.widget, "show_add_comment_button", True
+                    ),
+                },
+            )
+        )
 
     def required_fields(self):
         return [self.field_name]
@@ -554,7 +584,9 @@ class FieldPanel(EditHandler):
         try:
             model = self.model
         except AttributeError:
-            raise ImproperlyConfigured("%r must be bound to a model before calling db_field" % self)
+            raise ImproperlyConfigured(
+                "%r must be bound to a model before calling db_field" % self
+            )
 
         return model._meta.get_field(self.field_name)
 
@@ -568,8 +600,13 @@ class FieldPanel(EditHandler):
 
     def __repr__(self):
         return "<%s '%s' with model=%s instance=%s request=%s form=%s>" % (
-            self.__class__.__name__, self.field_name,
-            self.model, self.instance, self.request, self.form.__class__.__name__)
+            self.__class__.__name__,
+            self.field_name,
+            self.model,
+            self.instance,
+            self.request,
+            self.form.__class__.__name__,
+        )
 
 
 class RichTextFieldPanel(FieldPanel):
@@ -603,10 +640,13 @@ class BaseChooserPanel(FieldPanel):
     def render_as_field(self):
         instance_obj = self.get_chosen_item()
         context = {
-            'field': self.bound_field,
+            "field": self.bound_field,
             self.object_type_name: instance_obj,
-            'is_chosen': bool(instance_obj),  # DEPRECATED - passed to templates for backwards compatibility only
-            'show_add_comment_button': self.comments_enabled and getattr(self.bound_field.field.widget, 'show_add_comment_button', True),
+            "is_chosen": bool(
+                instance_obj
+            ),  # DEPRECATED - passed to templates for backwards compatibility only
+            "show_add_comment_button": self.comments_enabled
+            and getattr(self.bound_field.field.widget, "show_add_comment_button", True),
         }
         return mark_safe(render_to_string(self.field_template, context))
 
@@ -629,15 +669,17 @@ class PageChooserPanel(BaseChooserPanel):
 
     def clone_kwargs(self):
         return {
-            'field_name': self.field_name,
-            'page_type': self.page_type,
-            'can_choose_root': self.can_choose_root,
+            "field_name": self.field_name,
+            "page_type": self.page_type,
+            "can_choose_root": self.can_choose_root,
         }
 
     def widget_overrides(self):
-        return {self.field_name: widgets.AdminPageChooser(
-            target_models=self.target_models(),
-            can_choose_root=self.can_choose_root)}
+        return {
+            self.field_name: widgets.AdminPageChooser(
+                target_models=self.target_models(), can_choose_root=self.can_choose_root
+            )
+        }
 
     def target_models(self):
         if self.page_type:
@@ -664,8 +706,17 @@ class PageChooserPanel(BaseChooserPanel):
 
 
 class InlinePanel(EditHandler):
-    def __init__(self, relation_name, panels=None, heading='', label='',
-                 min_num=None, max_num=None, *args, **kwargs):
+    def __init__(
+        self,
+        relation_name,
+        panels=None,
+        heading="",
+        label="",
+        min_num=None,
+        max_num=None,
+        *args,
+        **kwargs,
+    ):
         super().__init__(*args, **kwargs)
         self.relation_name = relation_name
         self.panels = panels
@@ -691,8 +742,7 @@ class InlinePanel(EditHandler):
             return self.panels
         # Failing that, get it from the model
         return extract_panel_definitions_from_model_class(
-            self.db_field.related_model,
-            exclude=[self.db_field.field.name]
+            self.db_field.related_model, exclude=[self.db_field.field.name]
         )
 
     def get_child_edit_handler(self):
@@ -704,12 +754,12 @@ class InlinePanel(EditHandler):
         child_edit_handler = self.get_child_edit_handler()
         return {
             self.relation_name: {
-                'fields': child_edit_handler.required_fields(),
-                'widgets': child_edit_handler.widget_overrides(),
-                'min_num': self.min_num,
-                'validate_min': self.min_num is not None,
-                'max_num': self.max_num,
-                'validate_max': self.max_num is not None
+                "fields": child_edit_handler.required_fields(),
+                "widgets": child_edit_handler.widget_overrides(),
+                "min_num": self.min_num,
+                "validate_min": self.min_num is not None,
+                "max_num": self.max_num,
+                "validate_max": self.max_num is not None,
             }
         }
 
@@ -721,10 +771,14 @@ class InlinePanel(EditHandler):
 
         for panel in self.get_panel_definitions():
             field_comparisons.extend(
-                panel.bind_to(model=self.db_field.related_model)
-                .get_comparison())
+                panel.bind_to(model=self.db_field.related_model).get_comparison()
+            )
 
-        return [functools.partial(compare.ChildRelationComparison, self.db_field, field_comparisons)]
+        return [
+            functools.partial(
+                compare.ChildRelationComparison, self.db_field, field_comparisons
+            )
+        ]
 
     def on_model_bound(self):
         manager = getattr(self.model, self.relation_name)
@@ -743,14 +797,18 @@ class InlinePanel(EditHandler):
                 subform.fields[ORDERING_FIELD_NAME].widget = forms.HiddenInput()
 
             child_edit_handler = self.get_child_edit_handler()
-            self.children.append(child_edit_handler.bind_to(
-                instance=subform.instance, request=self.request, form=subform))
+            self.children.append(
+                child_edit_handler.bind_to(
+                    instance=subform.instance, request=self.request, form=subform
+                )
+            )
 
         # if this formset is valid, it may have been re-ordered; respect that
         # in case the parent form errored and we need to re-render
         if self.formset.can_order and self.formset.is_valid():
             self.children.sort(
-                key=lambda child: child.form.cleaned_data[ORDERING_FIELD_NAME] or 1)
+                key=lambda child: child.form.cleaned_data[ORDERING_FIELD_NAME] or 1
+            )
 
         empty_form = self.formset.empty_form
         empty_form.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput()
@@ -759,25 +817,34 @@ class InlinePanel(EditHandler):
 
         self.empty_child = self.get_child_edit_handler()
         self.empty_child = self.empty_child.bind_to(
-            instance=empty_form.instance, request=self.request, form=empty_form)
+            instance=empty_form.instance, request=self.request, form=empty_form
+        )
 
     template = "wagtailadmin/edit_handlers/inline_panel.html"
 
     def render(self):
-        formset = render_to_string(self.template, {
-            'self': self,
-            'can_order': self.formset.can_order,
-        })
+        formset = render_to_string(
+            self.template,
+            {
+                "self": self,
+                "can_order": self.formset.can_order,
+            },
+        )
         js = self.render_js_init()
         return widget_with_script(formset, js)
 
     js_template = "wagtailadmin/edit_handlers/inline_panel.js"
 
     def render_js_init(self):
-        return mark_safe(render_to_string(self.js_template, {
-            'self': self,
-            'can_order': self.formset.can_order,
-        }))
+        return mark_safe(
+            render_to_string(
+                self.js_template,
+                {
+                    "self": self,
+                    "can_order": self.formset.can_order,
+                },
+            )
+        )
 
 
 # This allows users to include the publishing panel in their own per-model override
@@ -786,14 +853,17 @@ class InlinePanel(EditHandler):
 class PublishingPanel(MultiFieldPanel):
     def __init__(self, **kwargs):
         updated_kwargs = {
-            'children': [
-                FieldRowPanel([
-                    FieldPanel('go_live_at'),
-                    FieldPanel('expire_at'),
-                ], classname="label-above"),
+            "children": [
+                FieldRowPanel(
+                    [
+                        FieldPanel("go_live_at"),
+                        FieldPanel("expire_at"),
+                    ],
+                    classname="label-above",
+                ),
             ],
-            'heading': gettext_lazy('Scheduled publishing'),
-            'classname': 'publishing',
+            "heading": gettext_lazy("Scheduled publishing"),
+            "classname": "publishing",
         }
         updated_kwargs.update(kwargs)
         super().__init__(**updated_kwargs)
@@ -801,40 +871,37 @@ class PublishingPanel(MultiFieldPanel):
 
 class PrivacyModalPanel(EditHandler):
     def __init__(self, **kwargs):
-        updated_kwargs = {
-            'heading': gettext_lazy('Privacy'),
-            'classname': 'privacy'
-        }
+        updated_kwargs = {"heading": gettext_lazy("Privacy"), "classname": "privacy"}
         updated_kwargs.update(kwargs)
         super().__init__(**updated_kwargs)
 
     def render(self):
-        content = render_to_string('wagtailadmin/pages/privacy_switch_panel.html', {
-            'self': self,
-            'page': self.instance,
-            'request': self.request
-        })
+        content = render_to_string(
+            "wagtailadmin/pages/privacy_switch_panel.html",
+            {"self": self, "page": self.instance, "request": self.request},
+        )
 
         from wagtail.admin.staticfiles import versioned_static
-        return mark_safe('{0}<script type="text/javascript" src="{1}"></script>'.format(
-            content,
-            versioned_static('wagtailadmin/js/privacy-switch.js'))
+
+        return mark_safe(
+            '{0}<script type="text/javascript" src="{1}"></script>'.format(
+                content, versioned_static("wagtailadmin/js/privacy-switch.js")
+            )
         )
 
 
 class CommentPanel(EditHandler):
-
     def required_fields(self):
         # Adds the comment notifications field to the form.
         # Note, this field is defined directly on WagtailAdminPageForm.
-        return ['comment_notifications']
+        return ["comment_notifications"]
 
     def required_formsets(self):
         # add the comments formset
         # we need to pass in the current user for validation on the formset
         # this could alternatively be done on the page form itself if we added the
         # comments formset there, but we typically only add fields via edit handlers
-        current_user = getattr(self.request, 'user', None)
+        current_user = getattr(self.request, "user", None)
 
         class CommentReplyFormWithRequest(CommentReplyForm):
             user = current_user
@@ -843,67 +910,70 @@ class CommentPanel(EditHandler):
             user = current_user
 
             class Meta:
-                formsets = {
-                    'replies': {
-                        'form': CommentReplyFormWithRequest
-                    }
-                }
+                formsets = {"replies": {"form": CommentReplyFormWithRequest}}
 
         return {
             COMMENTS_RELATION_NAME: {
-                'form': CommentFormWithRequest,
-                'fields': ['text', 'contentpath', 'position'],
-                'formset_name': 'comments',
+                "form": CommentFormWithRequest,
+                "fields": ["text", "contentpath", "position"],
+                "formset_name": "comments",
             }
         }
 
     template = "wagtailadmin/edit_handlers/comments/comment_panel.html"
-    declarations_template = "wagtailadmin/edit_handlers/comments/comment_declarations.html"
+    declarations_template = (
+        "wagtailadmin/edit_handlers/comments/comment_declarations.html"
+    )
 
     def html_declarations(self):
         return render_to_string(self.declarations_template)
 
     def get_context(self):
         def user_data(user):
-            return {
-                'name': user_display_name(user),
-                'avatar_url': avatar_url(user)
-            }
+            return {"name": user_display_name(user), "avatar_url": avatar_url(user)}
 
-        user = getattr(self.request, 'user', None)
+        user = getattr(self.request, "user", None)
         user_pks = {user.pk}
         serialized_comments = []
         bound = self.form.is_bound
-        comment_formset = self.form.formsets.get('comments')
+        comment_formset = self.form.formsets.get("comments")
         comment_forms = comment_formset.forms if comment_formset else []
         for form in comment_forms:
             # iterate over comments to retrieve users (to get display names) and serialized versions
             replies = []
-            for reply_form in form.formsets['replies'].forms:
+            for reply_form in form.formsets["replies"].forms:
                 user_pks.add(reply_form.instance.user_id)
                 reply_data = get_serializable_data_for_fields(reply_form.instance)
-                reply_data['deleted'] = reply_form.cleaned_data.get('DELETE', False) if bound else False
+                reply_data["deleted"] = (
+                    reply_form.cleaned_data.get("DELETE", False) if bound else False
+                )
                 replies.append(reply_data)
             user_pks.add(form.instance.user_id)
             data = get_serializable_data_for_fields(form.instance)
-            data['deleted'] = form.cleaned_data.get('DELETE', False) if bound else False
-            data['resolved'] = form.cleaned_data.get('resolved', False) if bound else form.instance.resolved_at is not None
-            data['replies'] = replies
+            data["deleted"] = form.cleaned_data.get("DELETE", False) if bound else False
+            data["resolved"] = (
+                form.cleaned_data.get("resolved", False)
+                if bound
+                else form.instance.resolved_at is not None
+            )
+            data["replies"] = replies
             serialized_comments.append(data)
 
         authors = {
             str(user.pk): user_data(user)
-            for user in get_user_model().objects.filter(pk__in=user_pks).select_related('wagtail_userprofile')
+            for user in get_user_model()
+            .objects.filter(pk__in=user_pks)
+            .select_related("wagtail_userprofile")
         }
 
         comments_data = {
-            'comments': serialized_comments,
-            'user': user.pk,
-            'authors': authors
+            "comments": serialized_comments,
+            "user": user.pk,
+            "authors": authors,
         }
 
         return {
-            'comments_data': comments_data,
+            "comments_data": comments_data,
         }
 
     def render(self):
@@ -914,18 +984,24 @@ class CommentPanel(EditHandler):
 # Now that we've defined EditHandlers, we can set up wagtailcore.Page to have some.
 def set_default_page_edit_handlers(cls):
     cls.content_panels = [
-        FieldPanel('title', classname="full title"),
+        FieldPanel("title", classname="full title"),
     ]
 
     cls.promote_panels = [
-        MultiFieldPanel([
-            FieldPanel('slug'),
-            FieldPanel('seo_title'),
-            FieldPanel('search_description'),
-        ], gettext_lazy('For search engines')),
-        MultiFieldPanel([
-            FieldPanel('show_in_menus'),
-        ], gettext_lazy('For site menus')),
+        MultiFieldPanel(
+            [
+                FieldPanel("slug"),
+                FieldPanel("seo_title"),
+                FieldPanel("search_description"),
+            ],
+            gettext_lazy("For search engines"),
+        ),
+        MultiFieldPanel(
+            [
+                FieldPanel("show_in_menus"),
+            ],
+            gettext_lazy("For site menus"),
+        ),
     ]
 
     cls.settings_panels = [
@@ -933,7 +1009,7 @@ def set_default_page_edit_handlers(cls):
         PrivacyModalPanel(),
     ]
 
-    if getattr(settings, 'WAGTAILADMIN_COMMENTS_ENABLED', True):
+    if getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True):
         cls.settings_panels.append(CommentPanel())
 
     cls.base_form_class = WagtailAdminPageForm
@@ -947,7 +1023,7 @@ def get_edit_handler(cls):
     """
     Get the EditHandler to use in the Wagtail admin when editing this page type.
     """
-    if hasattr(cls, 'edit_handler'):
+    if hasattr(cls, "edit_handler"):
         edit_handler = cls.edit_handler
     else:
         # construct a TabbedInterface made up of content_panels, promote_panels
@@ -955,15 +1031,17 @@ def get_edit_handler(cls):
         tabs = []
 
         if cls.content_panels:
-            tabs.append(ObjectList(cls.content_panels,
-                                   heading=gettext_lazy('Content')))
+            tabs.append(ObjectList(cls.content_panels, heading=gettext_lazy("Content")))
         if cls.promote_panels:
-            tabs.append(ObjectList(cls.promote_panels,
-                                   heading=gettext_lazy('Promote')))
+            tabs.append(ObjectList(cls.promote_panels, heading=gettext_lazy("Promote")))
         if cls.settings_panels:
-            tabs.append(ObjectList(cls.settings_panels,
-                                   heading=gettext_lazy('Settings'),
-                                   classname='settings'))
+            tabs.append(
+                ObjectList(
+                    cls.settings_panels,
+                    heading=gettext_lazy("Settings"),
+                    classname="settings",
+                )
+            )
 
         edit_handler = TabbedInterface(tabs, base_form_class=cls.base_form_class)
 
@@ -978,7 +1056,7 @@ def reset_page_edit_handler_cache(**kwargs):
     """
     Clear page edit handler cache when global WAGTAILADMIN_COMMENTS_ENABLED settings are changed
     """
-    if kwargs["setting"] == 'WAGTAILADMIN_COMMENTS_ENABLED':
+    if kwargs["setting"] == "WAGTAILADMIN_COMMENTS_ENABLED":
         set_default_page_edit_handlers(Page)
         for model in apps.get_models():
             if issubclass(model, Page):
@@ -987,7 +1065,7 @@ def reset_page_edit_handler_cache(**kwargs):
 
 class StreamFieldPanel(FieldPanel):
     def __init__(self, *args, **kwargs):
-        disable_comments = kwargs.pop('disable_comments', True)
+        disable_comments = kwargs.pop("disable_comments", True)
         super().__init__(*args, **kwargs, disable_comments=disable_comments)
 
     def classes(self):
@@ -996,7 +1074,7 @@ class StreamFieldPanel(FieldPanel):
 
         # In case of a validation error, BlockWidget will take care of outputting the error on the
         # relevant sub-block, so we don't want the stream block as a whole to be wrapped in an 'error' class.
-        if 'error' in classes:
+        if "error" in classes:
             classes.remove("error")
 
         return classes

+ 25 - 15
wagtail/admin/filters.py

@@ -1,10 +1,14 @@
 import django_filters
-
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django_filters.widgets import SuffixedMultiWidget
 
-from wagtail.admin.widgets import AdminDateInput, BooleanButtonSelect, ButtonSelect, FilteredSelect
+from wagtail.admin.widgets import (
+    AdminDateInput,
+    BooleanButtonSelect,
+    ButtonSelect,
+    FilteredSelect,
+)
 from wagtail.core.utils import get_content_type_label
 
 
@@ -12,11 +16,15 @@ class DateRangePickerWidget(SuffixedMultiWidget):
     """
     A widget allowing a start and end date to be picked.
     """
-    template_name = 'wagtailadmin/widgets/daterange_input.html'
-    suffixes = ['after', 'before']
+
+    template_name = "wagtailadmin/widgets/daterange_input.html"
+    suffixes = ["after", "before"]
 
     def __init__(self, attrs=None):
-        widgets = (AdminDateInput(attrs={'placeholder': _("Date from")}), AdminDateInput(attrs={'placeholder': _("Date to")}))
+        widgets = (
+            AdminDateInput(attrs={"placeholder": _("Date from")}),
+            AdminDateInput(attrs={"placeholder": _("Date to")}),
+        )
         super().__init__(widgets, attrs)
 
     def decompress(self, value):
@@ -31,11 +39,12 @@ class FilteredModelChoiceIterator(django_filters.fields.ModelChoiceIterator):
     returns (value, label, filter_value) so that FilteredSelect can drop filter_value into
     the data-filter-value attribute.
     """
+
     def choice(self, obj):
         return (
             self.field.prepare_value(obj),
             self.field.label_from_instance(obj),
-            self.field.get_filter_value(obj)
+            self.field.get_filter_value(obj),
         )
 
 
@@ -50,12 +59,13 @@ class FilteredModelChoiceField(django_filters.fields.ModelChoiceField):
         returns a queryset of related objects, or a function which accepts the model instance and
         returns such a queryset.
     """
+
     widget = FilteredSelect
     iterator = FilteredModelChoiceIterator
 
     def __init__(self, *args, **kwargs):
-        self.filter_accessor = kwargs.pop('filter_accessor')
-        filter_field = kwargs.pop('filter_field')
+        self.filter_accessor = kwargs.pop("filter_accessor")
+        filter_field = kwargs.pop("filter_field")
         super().__init__(*args, **kwargs)
         self.widget.filter_field = filter_field
 
@@ -73,7 +83,7 @@ class FilteredModelChoiceField(django_filters.fields.ModelChoiceField):
 
         # Turn this queryset into a list of IDs that will become the 'data-filter-value' used to
         # filter this listing
-        return queryset.values_list('pk', flat=True)
+        return queryset.values_list("pk", flat=True)
 
 
 class FilteredModelChoiceFilter(django_filters.ModelChoiceFilter):
@@ -81,23 +91,22 @@ class FilteredModelChoiceFilter(django_filters.ModelChoiceFilter):
 
 
 class WagtailFilterSet(django_filters.FilterSet):
-
     @classmethod
     def filter_for_lookup(cls, field, lookup_type):
         filter_class, params = super().filter_for_lookup(field, lookup_type)
 
         if filter_class == django_filters.ChoiceFilter:
-            params.setdefault('widget', ButtonSelect)
-            params.setdefault('empty_label', _("All"))
+            params.setdefault("widget", ButtonSelect)
+            params.setdefault("empty_label", _("All"))
 
         elif filter_class in [django_filters.DateFilter, django_filters.DateTimeFilter]:
-            params.setdefault('widget', AdminDateInput)
+            params.setdefault("widget", AdminDateInput)
 
         elif filter_class == django_filters.DateFromToRangeFilter:
-            params.setdefault('widget', DateRangePickerWidget)
+            params.setdefault("widget", DateRangePickerWidget)
 
         elif filter_class == django_filters.BooleanFilter:
-            params.setdefault('widget', BooleanButtonSelect)
+            params.setdefault("widget", BooleanButtonSelect)
 
         return filter_class, params
 
@@ -107,6 +116,7 @@ class ContentTypeModelChoiceField(django_filters.fields.ModelChoiceField):
     Custom ModelChoiceField for ContentType, to show the model verbose name as the label rather
     than the default 'wagtailcore | page' representation of a ContentType
     """
+
     def label_from_instance(self, obj):
         return get_content_type_label(obj)
 

+ 6 - 2
wagtail/admin/forms/__init__.py

@@ -1,5 +1,9 @@
 # definitions which are not being deprecated from wagtail.admin.forms
 from .models import (  # NOQA
-    DIRECT_FORM_FIELD_OVERRIDES, FORM_FIELD_OVERRIDES, WagtailAdminModelForm,
-    WagtailAdminModelFormMetaclass, formfield_for_dbfield)
+    DIRECT_FORM_FIELD_OVERRIDES,
+    FORM_FIELD_OVERRIDES,
+    WagtailAdminModelForm,
+    WagtailAdminModelFormMetaclass,
+    formfield_for_dbfield,
+)
 from .pages import WagtailAdminPageForm  # NOQA

+ 44 - 35
wagtail/admin/forms/account.py

@@ -1,21 +1,21 @@
 import warnings
-
 from operator import itemgetter
 
 import l18n
-
 from django import forms
 from django.contrib.auth import get_user_model
 from django.db.models.fields import BLANK_CHOICE_DASH
 from django.utils.translation import get_language_info
 from django.utils.translation import gettext_lazy as _
 
-from wagtail.admin.localization import get_available_admin_languages, get_available_admin_time_zones
+from wagtail.admin.localization import (
+    get_available_admin_languages,
+    get_available_admin_time_zones,
+)
 from wagtail.admin.widgets import SwitchInput
 from wagtail.core.models import UserPagePermissionsProxy
 from wagtail.users.models import UserProfile
 
-
 User = get_user_model()
 
 
@@ -24,35 +24,41 @@ class NotificationPreferencesForm(forms.ModelForm):
         super().__init__(*args, **kwargs)
         user_perms = UserPagePermissionsProxy(self.instance.user)
         if not user_perms.can_publish_pages():
-            del self.fields['submitted_notifications']
+            del self.fields["submitted_notifications"]
         if not user_perms.can_edit_pages():
-            del self.fields['approved_notifications']
-            del self.fields['rejected_notifications']
-            del self.fields['updated_comments_notifications']
+            del self.fields["approved_notifications"]
+            del self.fields["rejected_notifications"]
+            del self.fields["updated_comments_notifications"]
 
     class Meta:
         model = UserProfile
-        fields = ['submitted_notifications', 'approved_notifications', 'rejected_notifications', 'updated_comments_notifications']
+        fields = [
+            "submitted_notifications",
+            "approved_notifications",
+            "rejected_notifications",
+            "updated_comments_notifications",
+        ]
         widgets = {
-            'submitted_notifications': SwitchInput(),
-            'approved_notifications': SwitchInput(),
-            'rejected_notifications': SwitchInput(),
-            'updated_comments_notifications': SwitchInput(),
+            "submitted_notifications": SwitchInput(),
+            "approved_notifications": SwitchInput(),
+            "rejected_notifications": SwitchInput(),
+            "updated_comments_notifications": SwitchInput(),
         }
 
 
 def _get_language_choices():
     language_choices = [
-        (lang_code, get_language_info(lang_code)['name_local'])
+        (lang_code, get_language_info(lang_code)["name_local"])
         for lang_code, lang_name in get_available_admin_languages()
     ]
-    return sorted(BLANK_CHOICE_DASH + language_choices,
-                  key=lambda l: l[1].lower())
+    return sorted(BLANK_CHOICE_DASH + language_choices, key=lambda l: l[1].lower())
 
 
 def _get_time_zone_choices():
-    time_zones = [(tz, str(l18n.tz_fullnames.get(tz, tz)))
-                  for tz in get_available_admin_time_zones()]
+    time_zones = [
+        (tz, str(l18n.tz_fullnames.get(tz, tz)))
+        for tz in get_available_admin_time_zones()
+    ]
     time_zones.sort(key=itemgetter(1))
     return BLANK_CHOICE_DASH + time_zones
 
@@ -62,61 +68,64 @@ class LocalePreferencesForm(forms.ModelForm):
         super().__init__(*args, **kwargs)
 
         if len(get_available_admin_languages()) <= 1:
-            del self.fields['preferred_language']
+            del self.fields["preferred_language"]
 
         if len(get_available_admin_time_zones()) <= 1:
-            del self.fields['current_time_zone']
+            del self.fields["current_time_zone"]
 
     preferred_language = forms.ChoiceField(
-        required=False, choices=_get_language_choices,
-        label=_("Preferred language")
+        required=False, choices=_get_language_choices, label=_("Preferred language")
     )
 
     current_time_zone = forms.ChoiceField(
-        required=False, choices=_get_time_zone_choices,
-        label=_("Current time zone")
+        required=False, choices=_get_time_zone_choices, label=_("Current time zone")
     )
 
     class Meta:
         model = UserProfile
-        fields = ['preferred_language', 'current_time_zone']
+        fields = ["preferred_language", "current_time_zone"]
 
 
 class NameEmailForm(forms.ModelForm):
-    first_name = forms.CharField(required=True, label=_('First Name'))
-    last_name = forms.CharField(required=True, label=_('Last Name'))
-    email = forms.EmailField(required=True, label=_('Email'))
+    first_name = forms.CharField(required=True, label=_("First Name"))
+    last_name = forms.CharField(required=True, label=_("Last Name"))
+    email = forms.EmailField(required=True, label=_("Email"))
 
     def __init__(self, *args, **kwargs):
         from wagtail.admin.views.account import email_management_enabled
+
         super().__init__(*args, **kwargs)
 
         if not email_management_enabled():
-            del self.fields['email']
+            del self.fields["email"]
 
     class Meta:
         model = User
-        fields = ['first_name', 'last_name', 'email']
+        fields = ["first_name", "last_name", "email"]
 
 
 class AvatarPreferencesForm(forms.ModelForm):
-    avatar = forms.ImageField(
-        label=_("Upload a profile picture"), required=False
-    )
+    avatar = forms.ImageField(label=_("Upload a profile picture"), required=False)
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self._original_avatar = self.instance.avatar
 
     def save(self, commit=True):
-        if commit and self._original_avatar and (self._original_avatar != self.cleaned_data['avatar']):
+        if (
+            commit
+            and self._original_avatar
+            and (self._original_avatar != self.cleaned_data["avatar"])
+        ):
             # Call delete() on the storage backend directly, as calling self._original_avatar.delete()
             # will clear the now-updated field on self.instance too
             try:
                 self._original_avatar.storage.delete(self._original_avatar.name)
             except IOError:
                 # failure to delete the old avatar shouldn't prevent us from continuing
-                warnings.warn("Failed to delete old avatar file: %s" % self._original_avatar.name)
+                warnings.warn(
+                    "Failed to delete old avatar file: %s" % self._original_avatar.name
+                )
         super().save(commit=commit)
 
     class Meta:

+ 19 - 13
wagtail/admin/forms/auth.py

@@ -6,32 +6,37 @@ from django.utils.translation import gettext_lazy
 
 
 class LoginForm(AuthenticationForm):
-    username = forms.CharField(
-        max_length=254, widget=forms.TextInput())
+    username = forms.CharField(max_length=254, widget=forms.TextInput())
 
     password = forms.CharField(
-        widget=forms.PasswordInput(attrs={
-            'placeholder': gettext_lazy("Enter password"),
-        }))
+        widget=forms.PasswordInput(
+            attrs={
+                "placeholder": gettext_lazy("Enter password"),
+            }
+        )
+    )
 
     remember = forms.BooleanField(required=False)
 
     def __init__(self, request=None, *args, **kwargs):
         super().__init__(request=request, *args, **kwargs)
-        self.fields['username'].widget.attrs['placeholder'] = (
-            gettext_lazy("Enter your %s") % self.username_field.verbose_name)
+        self.fields["username"].widget.attrs["placeholder"] = (
+            gettext_lazy("Enter your %s") % self.username_field.verbose_name
+        )
 
     @property
     def extra_fields(self):
         for field_name in self.fields.keys():
-            if field_name not in ['username', 'password', 'remember']:
+            if field_name not in ["username", "password", "remember"]:
                 yield field_name, self[field_name]
 
 
 class PasswordResetForm(DjangoPasswordResetForm):
     email = forms.EmailField(
         label=gettext_lazy("Enter your email address to reset your password"),
-        max_length=254, required=True)
+        max_length=254,
+        required=True,
+    )
 
 
 class PasswordChangeForm(DjangoPasswordChangeForm):
@@ -41,13 +46,14 @@ class PasswordChangeForm(DjangoPasswordChangeForm):
     * the old-password field is not auto-focused
     * Fields are not marked as required
     """
+
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         try:
-            del self.fields['old_password'].widget.attrs['autofocus']
+            del self.fields["old_password"].widget.attrs["autofocus"]
         except KeyError:
             pass
 
-        self.fields['old_password'].required = False
-        self.fields['new_password1'].required = False
-        self.fields['new_password2'].required = False
+        self.fields["old_password"].required = False
+        self.fields["new_password1"].required = False
+        self.fields["new_password2"].required = False

+ 1 - 1
wagtail/admin/forms/choosers.py

@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
 class URLOrAbsolutePathValidator(validators.URLValidator):
     @staticmethod
     def is_absolute_path(value):
-        return value.startswith('/')
+        return value.startswith("/")
 
     def __call__(self, value):
         if URLOrAbsolutePathValidator.is_absolute_path(value):

+ 97 - 68
wagtail/admin/forms/collections.py

@@ -9,16 +9,19 @@ from django.template.loader import render_to_string
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
 
-from wagtail.core.models import Collection, CollectionViewRestriction, GroupCollectionPermission
+from wagtail.core.models import (
+    Collection,
+    CollectionViewRestriction,
+    GroupCollectionPermission,
+)
 
 from .view_restrictions import BaseViewRestrictionForm
 
 
 class CollectionViewRestrictionForm(BaseViewRestrictionForm):
-
     class Meta:
         model = CollectionViewRestriction
-        fields = ('restriction_type', 'password', 'groups')
+        fields = ("restriction_type", "password", "groups")
 
 
 class SelectWithDisabledOptions(forms.Select):
@@ -33,7 +36,7 @@ class SelectWithDisabledOptions(forms.Select):
     def create_option(self, name, value, *args, **kwargs):
         option_dict = super().create_option(name, value, *args, **kwargs)
         if value in self.disabled_values:
-            option_dict['attrs']['disabled'] = 'disabled'
+            option_dict["attrs"]["disabled"] = "disabled"
         return option_dict
 
 
@@ -53,12 +56,14 @@ class CollectionChoiceField(forms.ModelChoiceField):
         if queryset is None:
             self.widget.disabled_values = ()
         else:
-            self.widget.disabled_values = queryset.values_list(self.to_field_name or 'pk', flat=True)
+            self.widget.disabled_values = queryset.values_list(
+                self.to_field_name or "pk", flat=True
+            )
 
     disabled_queryset = property(_get_disabled_queryset, _set_disabled_queryset)
 
     def _set_queryset(self, queryset):
-        min_depth = self.queryset.aggregate(Min('depth'))['depth__min']
+        min_depth = self.queryset.aggregate(Min("depth"))["depth__min"]
         if min_depth is None:
             self._indentation_start_depth = 2
         else:
@@ -76,12 +81,12 @@ class CollectionForm(forms.ModelForm):
         help_text=gettext_lazy(
             "Select hierarchical position. Note: a collection cannot become a child of itself or one of its "
             "descendants."
-        )
+        ),
     )
 
     class Meta:
         model = Collection
-        fields = ('name',)
+        fields = ("name",)
 
     def clean_parent(self):
         """
@@ -95,13 +100,17 @@ class CollectionForm(forms.ModelForm):
         machinery to ignore the parent field for parent regardless of what the user submits.
         This methods enforces rule #3 when we are editing an existing collection.
         """
-        parent = self.cleaned_data['parent']
-        if not self.instance._state.adding and not parent.pk == self.initial.get('parent'):
-            old_descendants = list(self.instance.get_descendants(
-                inclusive=True).values_list('pk', flat=True)
+        parent = self.cleaned_data["parent"]
+        if not self.instance._state.adding and not parent.pk == self.initial.get(
+            "parent"
+        ):
+            old_descendants = list(
+                self.instance.get_descendants(inclusive=True).values_list(
+                    "pk", flat=True
+                )
             )
             if parent.pk in old_descendants:
-                raise ValidationError(gettext_lazy('Please select another parent'))
+                raise ValidationError(gettext_lazy("Please select another parent"))
         return parent
 
 
@@ -115,8 +124,9 @@ class BaseCollectionMemberForm(forms.ModelForm):
 
     Subclasses must define a 'permission_policy' attribute.
     """
+
     def __init__(self, *args, **kwargs):
-        user = kwargs.pop('user', None)
+        user = kwargs.pop("user", None)
 
         super().__init__(*args, **kwargs)
 
@@ -124,25 +134,26 @@ class BaseCollectionMemberForm(forms.ModelForm):
             self.collections = Collection.objects.all()
         else:
             self.collections = (
-                self.permission_policy.collections_user_has_permission_for(user, 'add')
+                self.permission_policy.collections_user_has_permission_for(user, "add")
             )
 
         if self.instance.pk:
             # editing an existing document; ensure that the list of available collections
             # includes its current collection
-            self.collections = (
-                self.collections | Collection.objects.filter(id=self.instance.collection_id)
+            self.collections = self.collections | Collection.objects.filter(
+                id=self.instance.collection_id
             )
 
         if len(self.collections) == 0:
             raise Exception(
-                "Cannot construct %s for a user with no collection permissions" % type(self)
+                "Cannot construct %s for a user with no collection permissions"
+                % type(self)
             )
         elif len(self.collections) == 1:
             # don't show collection field if only one collection is available
-            del self.fields['collection']
+            del self.fields["collection"]
         else:
-            self.fields['collection'].queryset = self.collections
+            self.fields["collection"].queryset = self.collections
 
     def save(self, commit=True):
         if len(self.collections) == 1:
@@ -162,6 +173,7 @@ class BaseGroupCollectionMemberPermissionFormSet(forms.BaseFormSet):
     default_prefix - prefix to use on form fields if one is not specified in __init__
     template = template filename
     """
+
     def __init__(self, data=None, files=None, instance=None, prefix=None):
         if prefix is None:
             prefix = self.default_prefix
@@ -176,24 +188,26 @@ class BaseGroupCollectionMemberPermissionFormSet(forms.BaseFormSet):
         for collection, collection_permissions in groupby(
             instance.collection_permissions.filter(
                 permission__in=self.permission_queryset
-            ).select_related('permission__content_type', 'collection').order_by('collection'),
-            lambda cp: cp.collection
+            )
+            .select_related("permission__content_type", "collection")
+            .order_by("collection"),
+            lambda cp: cp.collection,
         ):
-            initial_data.append({
-                'collection': collection,
-                'permissions': [cp.permission for cp in collection_permissions]
-            })
+            initial_data.append(
+                {
+                    "collection": collection,
+                    "permissions": [cp.permission for cp in collection_permissions],
+                }
+            )
 
-        super().__init__(
-            data, files, initial=initial_data, prefix=prefix
-        )
+        super().__init__(data, files, initial=initial_data, prefix=prefix)
         for form in self.forms:
-            form.fields['DELETE'].widget = forms.HiddenInput()
+            form.fields["DELETE"].widget = forms.HiddenInput()
 
     @property
     def empty_form(self):
         empty_form = super().empty_form
-        empty_form.fields['DELETE'].widget = forms.HiddenInput()
+        empty_form.fields["DELETE"].widget = forms.HiddenInput()
         return empty_form
 
     def clean(self):
@@ -203,16 +217,18 @@ class BaseGroupCollectionMemberPermissionFormSet(forms.BaseFormSet):
             return
 
         collections = [
-            form.cleaned_data['collection']
+            form.cleaned_data["collection"]
             for form in self.forms
             # need to check for presence of 'collection' in cleaned_data,
             # because a completely blank form passes validation
-            if form not in self.deleted_forms and 'collection' in form.cleaned_data
+            if form not in self.deleted_forms and "collection" in form.cleaned_data
         ]
         if len(set(collections)) != len(collections):
             # collections list contains duplicates
             raise forms.ValidationError(
-                _("You cannot have multiple permission records for the same collection.")
+                _(
+                    "You cannot have multiple permission records for the same collection."
+                )
             )
 
     @transaction.atomic
@@ -225,14 +241,17 @@ class BaseGroupCollectionMemberPermissionFormSet(forms.BaseFormSet):
 
         # get a set of (collection, permission) tuples for all ticked permissions
         forms_to_save = [
-            form for form in self.forms
-            if form not in self.deleted_forms and 'collection' in form.cleaned_data
+            form
+            for form in self.forms
+            if form not in self.deleted_forms and "collection" in form.cleaned_data
         ]
 
         final_permission_records = set()
         for form in forms_to_save:
-            for permission in form.cleaned_data['permissions']:
-                final_permission_records.add((form.cleaned_data['collection'], permission))
+            for permission in form.cleaned_data["permissions"]:
+                final_permission_records.add(
+                    (form.cleaned_data["collection"], permission)
+                )
 
         # fetch the group's existing collection permission records for this model,
         # and from that, build a list of records to be created / deleted
@@ -247,20 +266,24 @@ class BaseGroupCollectionMemberPermissionFormSet(forms.BaseFormSet):
             else:
                 permission_ids_to_delete.append(cp.id)
 
-        self.instance.collection_permissions.filter(id__in=permission_ids_to_delete).delete()
+        self.instance.collection_permissions.filter(
+            id__in=permission_ids_to_delete
+        ).delete()
 
         permissions_to_add = final_permission_records - permission_records_to_keep
-        GroupCollectionPermission.objects.bulk_create([
-            GroupCollectionPermission(
-                group=self.instance, collection=collection, permission=permission
-            )
-            for (collection, permission) in permissions_to_add
-        ])
+        GroupCollectionPermission.objects.bulk_create(
+            [
+                GroupCollectionPermission(
+                    group=self.instance, collection=collection, permission=permission
+                )
+                for (collection, permission) in permissions_to_add
+            ]
+        )
 
     def as_admin_panel(self):
         return render_to_string(
             self.template,
-            {'formset': self},
+            {"formset": self},
         )
 
 
@@ -270,17 +293,20 @@ def collection_member_permission_formset_factory(
 
     permission_queryset = Permission.objects.filter(
         content_type__app_label=model._meta.app_label,
-        codename__in=[codename for codename, short_label, long_label in permission_types]
-    ).select_related('content_type')
+        codename__in=[
+            codename for codename, short_label, long_label in permission_types
+        ],
+    ).select_related("content_type")
 
     if default_prefix is None:
-        default_prefix = '%s_permissions' % model._meta.model_name
+        default_prefix = "%s_permissions" % model._meta.model_name
 
     class PermissionMultipleChoiceField(forms.ModelMultipleChoiceField):
         """
         Allows the custom labels from ``permission_types`` to be applied to
         permission checkboxes for the ``CollectionMemberPermissionsForm`` below
         """
+
         def label_from_instance(self, obj):
             for codename, short_label, long_label in permission_types:
                 if codename == obj.codename:
@@ -293,42 +319,45 @@ def collection_member_permission_formset_factory(
         defines the permissions that are assigned to an entity
         (i.e. group or user) for a specific collection
         """
+
         collection = CollectionChoiceField(
             label=_("Collection"),
-            queryset=Collection.objects.all().prefetch_related('group_permissions'),
-            empty_label=None
+            queryset=Collection.objects.all().prefetch_related("group_permissions"),
+            empty_label=None,
         )
         permissions = PermissionMultipleChoiceField(
             queryset=permission_queryset,
             required=False,
-            widget=forms.CheckboxSelectMultiple
+            widget=forms.CheckboxSelectMultiple,
         )
 
     GroupCollectionMemberPermissionFormSet = type(
-        str('GroupCollectionMemberPermissionFormSet'),
-        (BaseGroupCollectionMemberPermissionFormSet, ),
+        str("GroupCollectionMemberPermissionFormSet"),
+        (BaseGroupCollectionMemberPermissionFormSet,),
         {
-            'permission_types': permission_types,
-            'permission_queryset': permission_queryset,
-            'default_prefix': default_prefix,
-            'template': template,
-        }
+            "permission_types": permission_types,
+            "permission_queryset": permission_queryset,
+            "default_prefix": default_prefix,
+            "template": template,
+        },
     )
 
     return forms.formset_factory(
         CollectionMemberPermissionsForm,
         formset=GroupCollectionMemberPermissionFormSet,
         extra=0,
-        can_delete=True
+        can_delete=True,
     )
 
 
-GroupCollectionManagementPermissionFormSet = collection_member_permission_formset_factory(
-    Collection,
-    [
-        ('add_collection', _("Add"), _("Add collections")),
-        ('change_collection', _("Edit"), _("Edit collections")),
-        ('delete_collection', _("Delete"), _("Delete collections")),
-    ],
-    'wagtailadmin/permissions/includes/collection_management_permissions_form.html'
+GroupCollectionManagementPermissionFormSet = (
+    collection_member_permission_formset_factory(
+        Collection,
+        [
+            ("add_collection", _("Add"), _("Add collections")),
+            ("change_collection", _("Edit"), _("Edit collections")),
+            ("delete_collection", _("Delete"), _("Delete collections")),
+        ],
+        "wagtailadmin/permissions/includes/collection_management_permissions_form.html",
+    )
 )

+ 15 - 6
wagtail/admin/forms/comments.py

@@ -9,7 +9,7 @@ class CommentReplyForm(WagtailAdminModelForm):
     user = None
 
     class Meta:
-        fields = ('text',)
+        fields = ("text",)
 
     def clean(self):
         cleaned_data = super().clean()
@@ -22,7 +22,9 @@ class CommentReplyForm(WagtailAdminModelForm):
             if any(field for field in self.changed_data):
                 # includes DELETION_FIELD_NAME, as users cannot delete each other's individual comment replies
                 # if deleting a whole thread, this should be done by deleting the parent Comment instead
-                self.add_error(None, ValidationError(_("You cannot edit another user's comment.")))
+                self.add_error(
+                    None, ValidationError(_("You cannot edit another user's comment."))
+                )
         return cleaned_data
 
 
@@ -30,6 +32,7 @@ class CommentForm(WagtailAdminModelForm):
     """
     This is designed to be subclassed and have the user overridden to enable user-based validation within the edit handler system
     """
+
     user = None
 
     resolved = BooleanField(required=False)
@@ -42,14 +45,20 @@ class CommentForm(WagtailAdminModelForm):
             self.instance.user = user
         elif self.instance.user != user:
             # trying to edit someone else's comment
-            if any(field for field in self.changed_data if field not in ['resolved', 'position']):
+            if any(
+                field
+                for field in self.changed_data
+                if field not in ["resolved", "position"]
+            ):
                 # users can resolve each other's base comments and change their positions within a field
-                self.add_error(None, ValidationError(_("You cannot edit another user's comment.")))
+                self.add_error(
+                    None, ValidationError(_("You cannot edit another user's comment."))
+                )
         return cleaned_data
 
     def save(self, *args, **kwargs):
-        if self.cleaned_data.get('resolved', False):
-            if not getattr(self.instance, 'resolved_at'):
+        if self.cleaned_data.get("resolved", False):
+            if not getattr(self.instance, "resolved_at"):
                 self.instance.resolved_at = now()
                 self.instance.resolved_by = self.user
         else:

+ 11 - 9
wagtail/admin/forms/models.py

@@ -7,18 +7,18 @@ from taggit.managers import TaggableManager
 from wagtail.admin import widgets
 from wagtail.admin.forms.tags import TagField
 
-
 # Form field properties to override whenever we encounter a model field
 # that matches one of these types - including subclasses
 
+
 def _get_tag_field_overrides(db_field):
-    return {'form_class': TagField, 'tag_model': db_field.related_model}
+    return {"form_class": TagField, "tag_model": db_field.related_model}
 
 
 FORM_FIELD_OVERRIDES = {
-    models.DateField: {'widget': widgets.AdminDateInput},
-    models.TimeField: {'widget': widgets.AdminTimeInput},
-    models.DateTimeField: {'widget': widgets.AdminDateTimeInput},
+    models.DateField: {"widget": widgets.AdminDateInput},
+    models.TimeField: {"widget": widgets.AdminTimeInput},
+    models.DateTimeField: {"widget": widgets.AdminDateTimeInput},
     TaggableManager: _get_tag_field_overrides,
 }
 
@@ -27,7 +27,7 @@ FORM_FIELD_OVERRIDES = {
 # (This allows us to override the widget for models.TextField, but leave
 # the RichTextField widget alone)
 DIRECT_FORM_FIELD_OVERRIDES = {
-    models.TextField: {'widget': widgets.AdminAutoHeightTextInput},
+    models.TextField: {"widget": widgets.AdminAutoHeightTextInput},
 }
 
 
@@ -69,10 +69,12 @@ class WagtailAdminModelFormMetaclass(ClusterFormMetaclass):
     extra_form_count = 0
 
     def __new__(cls, name, bases, attrs):
-        if 'formfield_callback' not in attrs or attrs['formfield_callback'] is None:
-            attrs['formfield_callback'] = formfield_for_dbfield
+        if "formfield_callback" not in attrs or attrs["formfield_callback"] is None:
+            attrs["formfield_callback"] = formfield_for_dbfield
 
-        new_class = super(WagtailAdminModelFormMetaclass, cls).__new__(cls, name, bases, attrs)
+        new_class = super(WagtailAdminModelFormMetaclass, cls).__new__(
+            cls, name, bases, attrs
+        )
         return new_class
 
     @classmethod

+ 96 - 51
wagtail/admin/forms/pages.py

@@ -14,29 +14,38 @@ from .view_restrictions import BaseViewRestrictionForm
 class CopyForm(forms.Form):
     def __init__(self, *args, **kwargs):
         # CopyPage must be passed a 'page' kwarg indicating the page to be copied
-        self.page = kwargs.pop('page')
-        self.user = kwargs.pop('user', None)
-        can_publish = kwargs.pop('can_publish')
+        self.page = kwargs.pop("page")
+        self.user = kwargs.pop("user", None)
+        can_publish = kwargs.pop("can_publish")
         super().__init__(*args, **kwargs)
-        self.fields['new_title'] = forms.CharField(initial=self.page.title, label=_("New title"))
-        allow_unicode = getattr(settings, 'WAGTAIL_ALLOW_UNICODE_SLUGS', True)
-        self.fields['new_slug'] = forms.SlugField(initial=self.page.slug, label=_("New slug"), allow_unicode=allow_unicode)
-        self.fields['new_parent_page'] = forms.ModelChoiceField(
+        self.fields["new_title"] = forms.CharField(
+            initial=self.page.title, label=_("New title")
+        )
+        allow_unicode = getattr(settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True)
+        self.fields["new_slug"] = forms.SlugField(
+            initial=self.page.slug, label=_("New slug"), allow_unicode=allow_unicode
+        )
+        self.fields["new_parent_page"] = forms.ModelChoiceField(
             initial=self.page.get_parent(),
             queryset=Page.objects.all(),
-            widget=widgets.AdminPageChooser(can_choose_root=True, user_perms='copy_to'),
+            widget=widgets.AdminPageChooser(can_choose_root=True, user_perms="copy_to"),
             label=_("New parent page"),
-            help_text=_("This copy will be a child of this given parent page.")
+            help_text=_("This copy will be a child of this given parent page."),
         )
         pages_to_copy = self.page.get_descendants(inclusive=True)
         subpage_count = pages_to_copy.count() - 1
         if subpage_count > 0:
-            self.fields['copy_subpages'] = forms.BooleanField(
-                required=False, initial=True, label=_("Copy subpages"),
+            self.fields["copy_subpages"] = forms.BooleanField(
+                required=False,
+                initial=True,
+                label=_("Copy subpages"),
                 help_text=ngettext(
                     "This will copy %(count)s subpage.",
                     "This will copy %(count)s subpages.",
-                    subpage_count) % {'count': subpage_count})
+                    subpage_count,
+                )
+                % {"count": subpage_count},
+            )
 
         if can_publish:
             pages_to_publish_count = pages_to_copy.live().count()
@@ -44,51 +53,68 @@ class CopyForm(forms.Form):
                 # In the specific case that there are no subpages, customise the field label and help text
                 if subpage_count == 0:
                     label = _("Publish copied page")
-                    help_text = _("This page is live. Would you like to publish its copy as well?")
+                    help_text = _(
+                        "This page is live. Would you like to publish its copy as well?"
+                    )
                 else:
                     label = _("Publish copies")
                     help_text = ngettext(
                         "%(count)s of the pages being copied is live. Would you like to publish its copy?",
                         "%(count)s of the pages being copied are live. Would you like to publish their copies?",
-                        pages_to_publish_count) % {'count': pages_to_publish_count}
+                        pages_to_publish_count,
+                    ) % {"count": pages_to_publish_count}
 
-                self.fields['publish_copies'] = forms.BooleanField(
+                self.fields["publish_copies"] = forms.BooleanField(
                     required=False, initial=False, label=label, help_text=help_text
                 )
 
             # Note that only users who can publish in the new parent page can create an alias.
             # This is because alias pages must always match their original page's state.
-            self.fields['alias'] = forms.BooleanField(
-                required=False, initial=False, label=_("Alias"),
-                help_text=_("Keep the new pages updated with future changes")
+            self.fields["alias"] = forms.BooleanField(
+                required=False,
+                initial=False,
+                label=_("Alias"),
+                help_text=_("Keep the new pages updated with future changes"),
             )
 
     def clean(self):
         cleaned_data = super().clean()
 
         # Make sure the slug isn't already in use
-        slug = cleaned_data.get('new_slug')
+        slug = cleaned_data.get("new_slug")
 
         # New parent page given in form or parent of source, if parent_page is empty
-        parent_page = cleaned_data.get('new_parent_page') or self.page.get_parent()
+        parent_page = cleaned_data.get("new_parent_page") or self.page.get_parent()
 
         # check if user is allowed to create a page at given location.
         if not parent_page.permissions_for_user(self.user).can_add_subpage():
-            self._errors['new_parent_page'] = self.error_class([
-                _("You do not have permission to copy to page \"%(page_title)s\"") % {'page_title': parent_page.specific_deferred.get_admin_display_title()}
-            ])
+            self._errors["new_parent_page"] = self.error_class(
+                [
+                    _('You do not have permission to copy to page "%(page_title)s"')
+                    % {
+                        "page_title": parent_page.specific_deferred.get_admin_display_title()
+                    }
+                ]
+            )
 
         # Count the pages with the same slug within the context of our copy's parent page
         if slug and parent_page.get_children().filter(slug=slug).count():
-            self._errors['new_slug'] = self.error_class(
-                [_("This slug is already in use within the context of its parent page \"%s\"") % parent_page]
+            self._errors["new_slug"] = self.error_class(
+                [
+                    _(
+                        'This slug is already in use within the context of its parent page "%s"'
+                    )
+                    % parent_page
+                ]
             )
             # The slug is no longer valid, hence remove it from cleaned_data
-            del cleaned_data['new_slug']
+            del cleaned_data["new_slug"]
 
         # Don't allow recursive copies into self
-        if cleaned_data.get('copy_subpages') and (self.page == parent_page or parent_page.is_descendant_of(self.page)):
-            self._errors['new_parent_page'] = self.error_class(
+        if cleaned_data.get("copy_subpages") and (
+            self.page == parent_page or parent_page.is_descendant_of(self.page)
+        ):
+            self._errors["new_parent_page"] = self.error_class(
                 [_("You cannot copy a page into itself when copying subpages")]
             )
 
@@ -96,14 +122,15 @@ class CopyForm(forms.Form):
 
 
 class PageViewRestrictionForm(BaseViewRestrictionForm):
-
     class Meta:
         model = PageViewRestriction
-        fields = ('restriction_type', 'password', 'groups')
+        fields = ("restriction_type", "password", "groups")
 
 
 class WagtailAdminPageForm(WagtailAdminModelForm):
-    comment_notifications = forms.BooleanField(widget=forms.CheckboxInput(), required=False)
+    comment_notifications = forms.BooleanField(
+        widget=forms.CheckboxInput(), required=False
+    )
 
     # Could be set to False by a subclass constructed by TabbedInterface
     show_comments_toggle = True
@@ -111,63 +138,81 @@ class WagtailAdminPageForm(WagtailAdminModelForm):
     class Meta:
         # (dealing with Treebeard's tree-related fields that really should have
         # been editable=False)
-        exclude = ['content_type', 'path', 'depth', 'numchild']
-
-    def __init__(self, data=None, files=None, parent_page=None, subscription=None, *args, **kwargs):
+        exclude = ["content_type", "path", "depth", "numchild"]
+
+    def __init__(
+        self,
+        data=None,
+        files=None,
+        parent_page=None,
+        subscription=None,
+        *args,
+        **kwargs,
+    ):
         self.subscription = subscription
 
-        initial = kwargs.pop('initial', {})
+        initial = kwargs.pop("initial", {})
         if self.subscription:
-            initial['comment_notifications'] = subscription.comment_notifications
+            initial["comment_notifications"] = subscription.comment_notifications
 
         super().__init__(data, files, *args, initial=initial, **kwargs)
 
         self.parent_page = parent_page
 
         if not self.show_comments_toggle:
-            del self.fields['comment_notifications']
+            del self.fields["comment_notifications"]
 
     def save(self, commit=True):
         # Save comment notifications updates to PageSubscription
         if self.show_comments_toggle and self.subscription:
-            self.subscription.comment_notifications = self.cleaned_data['comment_notifications']
+            self.subscription.comment_notifications = self.cleaned_data[
+                "comment_notifications"
+            ]
             if commit:
                 self.subscription.save()
 
         return super().save(commit=commit)
 
     def is_valid(self):
-        comments = self.formsets.get('comments')
+        comments = self.formsets.get("comments")
         # Remove the comments formset if the management form is invalid
         if comments and not comments.management_form.is_valid():
-            del self.formsets['comments']
+            del self.formsets["comments"]
         return super().is_valid()
 
     def clean(self):
         cleaned_data = super().clean()
-        if 'slug' in self.cleaned_data:
+        if "slug" in self.cleaned_data:
             if not Page._slug_is_available(
-                cleaned_data['slug'], self.parent_page, self.instance
+                cleaned_data["slug"], self.parent_page, self.instance
             ):
-                self.add_error('slug', forms.ValidationError(_("This slug is already in use")))
+                self.add_error(
+                    "slug", forms.ValidationError(_("This slug is already in use"))
+                )
 
         # Check scheduled publishing fields
-        go_live_at = cleaned_data.get('go_live_at')
-        expire_at = cleaned_data.get('expire_at')
+        go_live_at = cleaned_data.get("go_live_at")
+        expire_at = cleaned_data.get("expire_at")
 
         # Go live must be before expire
         if go_live_at and expire_at:
             if go_live_at > expire_at:
-                msg = _('Go live date/time must be before expiry date/time')
-                self.add_error('go_live_at', forms.ValidationError(msg))
-                self.add_error('expire_at', forms.ValidationError(msg))
+                msg = _("Go live date/time must be before expiry date/time")
+                self.add_error("go_live_at", forms.ValidationError(msg))
+                self.add_error("expire_at", forms.ValidationError(msg))
 
         # Expire at must be in the future
         if expire_at and expire_at < timezone.now():
-            self.add_error('expire_at', forms.ValidationError(_('Expiry date/time must be in the future')))
+            self.add_error(
+                "expire_at",
+                forms.ValidationError(_("Expiry date/time must be in the future")),
+            )
 
         # Don't allow an existing first_published_at to be unset by clearing the field
-        if 'first_published_at' in cleaned_data and not cleaned_data['first_published_at']:
-            del cleaned_data['first_published_at']
+        if (
+            "first_published_at" in cleaned_data
+            and not cleaned_data["first_published_at"]
+        ):
+            del cleaned_data["first_published_at"]
 
         return cleaned_data

+ 2 - 2
wagtail/admin/forms/search.py

@@ -5,8 +5,8 @@ from django.utils.translation import gettext_lazy
 
 class SearchForm(forms.Form):
     def __init__(self, *args, **kwargs):
-        placeholder = kwargs.pop('placeholder', _("Search"))
+        placeholder = kwargs.pop("placeholder", _("Search"))
         super().__init__(*args, **kwargs)
-        self.fields['q'].widget.attrs = {'placeholder': placeholder}
+        self.fields["q"].widget.attrs = {"placeholder": placeholder}
 
     q = forms.CharField(label=gettext_lazy("Search term"), widget=forms.TextInput())

+ 7 - 4
wagtail/admin/forms/tags.py

@@ -8,11 +8,12 @@ class TagField(TaggitTagField):
     """
     Extends taggit's TagField with the option to prevent creating tags that do not already exist
     """
+
     widget = AdminTagWidget
 
     def __init__(self, *args, **kwargs):
-        self.tag_model = kwargs.pop('tag_model', None)
-        self.free_tagging = kwargs.pop('free_tagging', None)
+        self.tag_model = kwargs.pop("tag_model", None)
+        self.free_tagging = kwargs.pop("free_tagging", None)
 
         super().__init__(*args, **kwargs)
 
@@ -25,7 +26,7 @@ class TagField(TaggitTagField):
             self.widget.tag_model = self.tag_model
 
         if self.free_tagging is None:
-            self.free_tagging = getattr(self.tag_model, 'free_tagging', True)
+            self.free_tagging = getattr(self.tag_model, "free_tagging", True)
         else:
             self.widget.free_tagging = self.free_tagging
 
@@ -35,7 +36,9 @@ class TagField(TaggitTagField):
         if not self.free_tagging:
             # filter value to just the tags that already exist in tag_model
             value = list(
-                self.tag_model.objects.filter(name__in=value).values_list('name', flat=True)
+                self.tag_model.objects.filter(name__in=value).values_list(
+                    "name", flat=True
+                )
             )
 
         return value

+ 21 - 11
wagtail/admin/forms/view_restrictions.py

@@ -8,27 +8,37 @@ from wagtail.core.models import BaseViewRestriction
 
 class BaseViewRestrictionForm(forms.ModelForm):
     restriction_type = forms.ChoiceField(
-        label=gettext_lazy("Visibility"), choices=BaseViewRestriction.RESTRICTION_CHOICES,
-        widget=forms.RadioSelect)
+        label=gettext_lazy("Visibility"),
+        choices=BaseViewRestriction.RESTRICTION_CHOICES,
+        widget=forms.RadioSelect,
+    )
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        self.fields['groups'].widget = forms.CheckboxSelectMultiple()
-        self.fields['groups'].queryset = Group.objects.all()
+        self.fields["groups"].widget = forms.CheckboxSelectMultiple()
+        self.fields["groups"].queryset = Group.objects.all()
 
     def clean_password(self):
-        password = self.cleaned_data.get('password')
-        if self.cleaned_data.get('restriction_type') == BaseViewRestriction.PASSWORD and not password:
-            raise forms.ValidationError(_("This field is required."), code='invalid')
+        password = self.cleaned_data.get("password")
+        if (
+            self.cleaned_data.get("restriction_type") == BaseViewRestriction.PASSWORD
+            and not password
+        ):
+            raise forms.ValidationError(_("This field is required."), code="invalid")
         return password
 
     def clean_groups(self):
-        groups = self.cleaned_data.get('groups')
-        if self.cleaned_data.get('restriction_type') == BaseViewRestriction.GROUPS and not groups:
-            raise forms.ValidationError(_("Please select at least one group."), code='invalid')
+        groups = self.cleaned_data.get("groups")
+        if (
+            self.cleaned_data.get("restriction_type") == BaseViewRestriction.GROUPS
+            and not groups
+        ):
+            raise forms.ValidationError(
+                _("Please select at least one group."), code="invalid"
+            )
         return groups
 
     class Meta:
         model = BaseViewRestriction
-        fields = ('restriction_type', 'password', 'groups')
+        fields = ("restriction_type", "password", "groups")

+ 51 - 32
wagtail/admin/forms/workflows.py

@@ -13,20 +13,21 @@ from wagtail.core.utils import get_model_string
 
 
 class TaskChooserSearchForm(forms.Form):
-    q = forms.CharField(label=__("Search term"), widget=forms.TextInput(), required=False)
+    q = forms.CharField(
+        label=__("Search term"), widget=forms.TextInput(), required=False
+    )
 
     def __init__(self, *args, task_type_choices=None, **kwargs):
-        placeholder = kwargs.pop('placeholder', _("Search"))
+        placeholder = kwargs.pop("placeholder", _("Search"))
         super().__init__(*args, **kwargs)
-        self.fields['q'].widget.attrs = {'placeholder': placeholder}
+        self.fields["q"].widget.attrs = {"placeholder": placeholder}
 
         # Add task type filter if there is more than one task type option
         if task_type_choices and len(task_type_choices) > 1:
-            self.fields['task_type'] = forms.ChoiceField(
+            self.fields["task_type"] = forms.ChoiceField(
                 choices=(
                     # Append an "All types" choice to the beginning
                     [(None, _("All types"))]
-
                     # The task type choices that are passed in use the models as values, we need
                     # to convert these to something that can be represented in HTML
                     + [
@@ -34,20 +35,19 @@ class TaskChooserSearchForm(forms.Form):
                         for model, verbose_name in task_type_choices
                     ]
                 ),
-                required=False
+                required=False,
             )
 
         # Save a mapping of task_type values back to the model that we can reference later
         self.task_type_choices = {
-            get_model_string(model): model
-            for model, verbose_name in task_type_choices
+            get_model_string(model): model for model, verbose_name in task_type_choices
         }
 
     def is_searching(self):
         """
         Returns True if the user typed a search query
         """
-        return self.is_valid() and bool(self.cleaned_data.get('q'))
+        return self.is_valid() and bool(self.cleaned_data.get("q"))
 
     @cached_property
     def task_model(self):
@@ -64,7 +64,7 @@ class TaskChooserSearchForm(forms.Form):
             return models[0]
 
         elif self.is_valid():
-            model_name = self.cleaned_data.get('task_type')
+            model_name = self.cleaned_data.get("task_type")
             if model_name and model_name in self.task_type_choices:
                 return self.task_type_choices[model_name]
 
@@ -77,36 +77,41 @@ class TaskChooserSearchForm(forms.Form):
 class WorkflowPageForm(forms.ModelForm):
     page = forms.ModelChoiceField(
         queryset=Page.objects.all(),
-        widget=widgets.AdminPageChooser(
-            target_models=[Page],
-            can_choose_root=True
-        )
+        widget=widgets.AdminPageChooser(target_models=[Page], can_choose_root=True),
     )
 
     class Meta:
         model = WorkflowPage
-        fields = ['page']
+        fields = ["page"]
 
     def clean(self):
-        page = self.cleaned_data.get('page')
+        page = self.cleaned_data.get("page")
         try:
             existing_workflow = page.workflowpage.workflow
-            if not self.errors and existing_workflow != self.cleaned_data['workflow']:
+            if not self.errors and existing_workflow != self.cleaned_data["workflow"]:
                 # If the form has no errors, Page has an existing Workflow assigned, that Workflow is not
                 # the selected Workflow, and overwrite_existing is not True, add a new error. This should be used to
                 # trigger the confirmation message in the view. This is why this error is only added if there are no
                 # other errors - confirmation should be the final step.
-                self.add_error('page', ValidationError(_("This page already has workflow '{0}' assigned.").format(existing_workflow), code='existing_workflow'))
+                self.add_error(
+                    "page",
+                    ValidationError(
+                        _("This page already has workflow '{0}' assigned.").format(
+                            existing_workflow
+                        ),
+                        code="existing_workflow",
+                    ),
+                )
         except AttributeError:
             pass
 
     def save(self, commit=False):
-        page = self.cleaned_data['page']
+        page = self.cleaned_data["page"]
 
         if commit:
             WorkflowPage.objects.update_or_create(
                 page=page,
-                defaults={'workflow': self.cleaned_data['workflow']},
+                defaults={"workflow": self.cleaned_data["workflow"]},
             )
 
 
@@ -115,12 +120,12 @@ class BaseWorkflowPagesFormSet(forms.BaseInlineFormSet):
         super().__init__(*args, **kwargs)
 
         for form in self.forms:
-            form.fields['DELETE'].widget = forms.HiddenInput()
+            form.fields["DELETE"].widget = forms.HiddenInput()
 
     @property
     def empty_form(self):
         empty_form = super().empty_form
-        empty_form.fields['DELETE'].widget = forms.HiddenInput()
+        empty_form.fields["DELETE"].widget = forms.HiddenInput()
         return empty_form
 
     def clean(self):
@@ -130,19 +135,27 @@ class BaseWorkflowPagesFormSet(forms.BaseInlineFormSet):
             return
 
         pages = [
-            form.cleaned_data['page']
+            form.cleaned_data["page"]
             for form in self.forms
             # need to check for presence of 'page' in cleaned_data,
             # because a completely blank form passes validation
-            if form not in self.deleted_forms and 'page' in form.cleaned_data
+            if form not in self.deleted_forms and "page" in form.cleaned_data
         ]
         if len(set(pages)) != len(pages):
             # pages list contains duplicates
-            raise forms.ValidationError(_("You cannot assign this workflow to the same page multiple times."))
+            raise forms.ValidationError(
+                _("You cannot assign this workflow to the same page multiple times.")
+            )
 
 
 WorkflowPagesFormSet = forms.inlineformset_factory(
-    Workflow, WorkflowPage, form=WorkflowPageForm, formset=BaseWorkflowPagesFormSet, extra=1, can_delete=True, fields=['page']
+    Workflow,
+    WorkflowPage,
+    form=WorkflowPageForm,
+    formset=BaseWorkflowPagesFormSet,
+    extra=1,
+    can_delete=True,
+    fields=["page"],
 )
 
 
@@ -163,11 +176,11 @@ def get_task_form_class(task_model, for_edit=False):
         task_model,
         form=BaseTaskForm,
         fields=fields,
-        widgets=getattr(task_model, 'admin_form_widgets', {})
+        widgets=getattr(task_model, "admin_form_widgets", {}),
     )
 
     if for_edit:
-        for field_name in getattr(task_model, 'admin_form_readonly_on_edit_fields', []):
+        for field_name in getattr(task_model, "admin_form_readonly_on_edit_fields", []):
             if field_name not in form_class.base_fields:
                 raise ImproperlyConfigured(
                     "`%s.admin_form_readonly_on_edit_fields` contains the field "
@@ -191,10 +204,16 @@ def get_workflow_edit_handler():
     # this decision later if we decide to allow custom fields on Workflows.
 
     panels = [
-        FieldPanel("name", heading=_("Give your workflow a name"), classname="full title"),
-        InlinePanel("workflow_tasks", [
-            FieldPanel('task', widget=AdminTaskChooser(show_clear_link=False)),
-        ], heading=_("Add tasks to your workflow")),
+        FieldPanel(
+            "name", heading=_("Give your workflow a name"), classname="full title"
+        ),
+        InlinePanel(
+            "workflow_tasks",
+            [
+                FieldPanel("task", widget=AdminTaskChooser(show_clear_link=False)),
+            ],
+            heading=_("Add tasks to your workflow"),
+        ),
     ]
     edit_handler = ObjectList(panels, base_form_class=WagtailAdminModelForm)
     return edit_handler.bind_to(model=Workflow)

+ 5 - 4
wagtail/admin/jinja2tags.py

@@ -1,5 +1,4 @@
 import jinja2
-
 from jinja2.ext import Extension
 
 from .templatetags.wagtailuserbar import wagtailuserbar
@@ -9,9 +8,11 @@ class WagtailUserbarExtension(Extension):
     def __init__(self, environment):
         super().__init__(environment)
 
-        self.environment.globals.update({
-            'wagtailuserbar': jinja2.contextfunction(wagtailuserbar),
-        })
+        self.environment.globals.update(
+            {
+                "wagtailuserbar": jinja2.contextfunction(wagtailuserbar),
+            }
+        )
 
 
 # Nicer import names

+ 122 - 123
wagtail/admin/localization.py

@@ -1,48 +1,46 @@
 import pytz
-
 from django.conf import settings
 from django.utils.dates import MONTHS, WEEKDAYS, WEEKDAYS_ABBR
 from django.utils.translation import gettext as _
 
-
 # Wagtail languages with >=90% coverage
 # This list is manually maintained
 WAGTAILADMIN_PROVIDED_LANGUAGES = [
-    ('ar', 'Arabic'),
-    ('ca', 'Catalan'),
-    ('cs', 'Czech'),
-    ('de', 'German'),
-    ('el', 'Greek'),
-    ('en', 'English'),
-    ('es', 'Spanish'),
-    ('et', 'Estonian'),
-    ('fi', 'Finnish'),
-    ('fr', 'French'),
-    ('gl', 'Galician'),
-    ('hr', 'Croatian'),
-    ('hu', 'Hungarian'),
-    ('id-id', 'Indonesian'),
-    ('is-is', 'Icelandic'),
-    ('it', 'Italian'),
-    ('ja', 'Japanese'),
-    ('ko', 'Korean'),
-    ('lt', 'Lithuanian'),
-    ('mn', 'Mongolian'),
-    ('nb', 'Norwegian Bokmål'),
-    ('nl', 'Dutch'),
-    ('fa', 'Persian'),
-    ('pl', 'Polish'),
-    ('pt-br', 'Brazilian Portuguese'),
-    ('pt-pt', 'Portuguese'),
-    ('ro', 'Romanian'),
-    ('ru', 'Russian'),
-    ('sv', 'Swedish'),
-    ('sk-sk', 'Slovak'),
-    ('th', 'Thai'),
-    ('tr', 'Turkish'),
-    ('uk', 'Ukrainian'),
-    ('zh-hans', 'Chinese (Simplified)'),
-    ('zh-hant', 'Chinese (Traditional)'),
+    ("ar", "Arabic"),
+    ("ca", "Catalan"),
+    ("cs", "Czech"),
+    ("de", "German"),
+    ("el", "Greek"),
+    ("en", "English"),
+    ("es", "Spanish"),
+    ("et", "Estonian"),
+    ("fi", "Finnish"),
+    ("fr", "French"),
+    ("gl", "Galician"),
+    ("hr", "Croatian"),
+    ("hu", "Hungarian"),
+    ("id-id", "Indonesian"),
+    ("is-is", "Icelandic"),
+    ("it", "Italian"),
+    ("ja", "Japanese"),
+    ("ko", "Korean"),
+    ("lt", "Lithuanian"),
+    ("mn", "Mongolian"),
+    ("nb", "Norwegian Bokmål"),
+    ("nl", "Dutch"),
+    ("fa", "Persian"),
+    ("pl", "Polish"),
+    ("pt-br", "Brazilian Portuguese"),
+    ("pt-pt", "Portuguese"),
+    ("ro", "Romanian"),
+    ("ru", "Russian"),
+    ("sv", "Swedish"),
+    ("sk-sk", "Slovak"),
+    ("th", "Thai"),
+    ("tr", "Turkish"),
+    ("uk", "Ukrainian"),
+    ("zh-hans", "Chinese (Simplified)"),
+    ("zh-hant", "Chinese (Traditional)"),
 ]
 
 
@@ -50,108 +48,109 @@ WAGTAILADMIN_PROVIDED_LANGUAGES = [
 # as the wagtailConfig.STRINGS object
 def get_js_translation_strings():
     return {
-        'DELETE': _('Delete'),
-        'EDIT': _('Edit'),
-        'PAGE': _('Page'),
-        'PAGES': _('Pages'),
-        'LOADING': _('Loading…'),
-        'NO_RESULTS': _('No results'),
-        'SERVER_ERROR': _('Server Error'),
-        'SEE_ALL': _('See all'),
-        'CLOSE_EXPLORER': _('Close explorer'),
-        'ALT_TEXT': _('Alt text'),
-        'DECORATIVE_IMAGE': _('Decorative image'),
-        'WRITE_HERE': _('Write here…'),
-        'HORIZONTAL_LINE': _('Horizontal line'),
-        'LINE_BREAK': _('Line break'),
-        'UNDO': _('Undo'),
-        'REDO': _('Redo'),
-        'RELOAD_PAGE': _('Reload the page'),
-        'RELOAD_EDITOR': _('Reload saved content'),
-        'SHOW_LATEST_CONTENT': _('Show latest content'),
-        'SHOW_ERROR': _('Show error'),
-        'EDITOR_CRASH': _('The editor just crashed. Content has been reset to the last saved version.'),
-        'BROKEN_LINK': _('Broken link'),
-        'MISSING_DOCUMENT': _('Missing document'),
-        'CLOSE': _('Close'),
-        'EDIT_PAGE': _('Edit \'{title}\''),
-        'VIEW_CHILD_PAGES_OF_PAGE': _('View child pages of \'{title}\''),
-        'PAGE_EXPLORER': _('Page explorer'),
-        'SAVE': _('Save'),
-        'SAVING': _('Saving...'),
-        'CANCEL': _('Cancel'),
-        'DELETING': _('Deleting...'),
-        'ADD_A_COMMENT': _('Add a comment'),
-        'SHOW_COMMENTS': _('Show comments'),
-        'REPLY': _('Reply'),
-        'RESOLVE': _('Resolve'),
-        'RETRY': _('Retry'),
-        'DELETE_ERROR': _('Delete error'),
-        'CONFIRM_DELETE_COMMENT': _('Are you sure?'),
-        'SAVE_ERROR': _('Save error'),
-        'SAVE_COMMENT_WARNING': _('This will be saved when the page is saved'),
-        'FOCUS_COMMENT': _('Focus comment'),
-        'UNFOCUS_COMMENT': _('Unfocus comment'),
-        'COMMENT': _('Comment'),
-        'MORE_ACTIONS': _('More actions'),
-        'SAVE_PAGE_TO_ADD_COMMENT': _('Save the page to add this comment'),
-        'SAVE_PAGE_TO_SAVE_COMMENT_CHANGES': _('Save the page to save this comment'),
-        'SAVE_PAGE_TO_SAVE_REPLY': _('Save the page to save this reply'),
-        'TOGGLE_SIDEBAR': _('Toggle sidebar'),
-        'MAIN_MENU': _('Main menu'),
-        'DASHBOARD': _('Dashboard'),
-        'EDIT_YOUR_ACCOUNT': _('Edit your account'),
-        'SEARCH': _('Search'),
-
-        'MONTHS': [str(m) for m in MONTHS.values()],
-
+        "DELETE": _("Delete"),
+        "EDIT": _("Edit"),
+        "PAGE": _("Page"),
+        "PAGES": _("Pages"),
+        "LOADING": _("Loading…"),
+        "NO_RESULTS": _("No results"),
+        "SERVER_ERROR": _("Server Error"),
+        "SEE_ALL": _("See all"),
+        "CLOSE_EXPLORER": _("Close explorer"),
+        "ALT_TEXT": _("Alt text"),
+        "DECORATIVE_IMAGE": _("Decorative image"),
+        "WRITE_HERE": _("Write here…"),
+        "HORIZONTAL_LINE": _("Horizontal line"),
+        "LINE_BREAK": _("Line break"),
+        "UNDO": _("Undo"),
+        "REDO": _("Redo"),
+        "RELOAD_PAGE": _("Reload the page"),
+        "RELOAD_EDITOR": _("Reload saved content"),
+        "SHOW_LATEST_CONTENT": _("Show latest content"),
+        "SHOW_ERROR": _("Show error"),
+        "EDITOR_CRASH": _(
+            "The editor just crashed. Content has been reset to the last saved version."
+        ),
+        "BROKEN_LINK": _("Broken link"),
+        "MISSING_DOCUMENT": _("Missing document"),
+        "CLOSE": _("Close"),
+        "EDIT_PAGE": _("Edit '{title}'"),
+        "VIEW_CHILD_PAGES_OF_PAGE": _("View child pages of '{title}'"),
+        "PAGE_EXPLORER": _("Page explorer"),
+        "SAVE": _("Save"),
+        "SAVING": _("Saving..."),
+        "CANCEL": _("Cancel"),
+        "DELETING": _("Deleting..."),
+        "ADD_A_COMMENT": _("Add a comment"),
+        "SHOW_COMMENTS": _("Show comments"),
+        "REPLY": _("Reply"),
+        "RESOLVE": _("Resolve"),
+        "RETRY": _("Retry"),
+        "DELETE_ERROR": _("Delete error"),
+        "CONFIRM_DELETE_COMMENT": _("Are you sure?"),
+        "SAVE_ERROR": _("Save error"),
+        "SAVE_COMMENT_WARNING": _("This will be saved when the page is saved"),
+        "FOCUS_COMMENT": _("Focus comment"),
+        "UNFOCUS_COMMENT": _("Unfocus comment"),
+        "COMMENT": _("Comment"),
+        "MORE_ACTIONS": _("More actions"),
+        "SAVE_PAGE_TO_ADD_COMMENT": _("Save the page to add this comment"),
+        "SAVE_PAGE_TO_SAVE_COMMENT_CHANGES": _("Save the page to save this comment"),
+        "SAVE_PAGE_TO_SAVE_REPLY": _("Save the page to save this reply"),
+        "TOGGLE_SIDEBAR": _("Toggle sidebar"),
+        "MAIN_MENU": _("Main menu"),
+        "DASHBOARD": _("Dashboard"),
+        "EDIT_YOUR_ACCOUNT": _("Edit your account"),
+        "SEARCH": _("Search"),
+        "MONTHS": [str(m) for m in MONTHS.values()],
         # Django's WEEKDAYS list begins on Monday, but ours should start on Sunday, so start
         # counting from -1 and use modulo 7 to get an array index
-        'WEEKDAYS': [str(WEEKDAYS[d % 7]) for d in range(-1, 6)],
-        'WEEKDAYS_SHORT': [str(WEEKDAYS_ABBR[d % 7]) for d in range(-1, 6)],
-
+        "WEEKDAYS": [str(WEEKDAYS[d % 7]) for d in range(-1, 6)],
+        "WEEKDAYS_SHORT": [str(WEEKDAYS_ABBR[d % 7]) for d in range(-1, 6)],
         # used by bulk actions
-        'BULK_ACTIONS': {
-            'PAGE': {
-                'SINGULAR': _('1 page selected'),
-                'PLURAL': _("{0} pages selected"),
-                'ALL': _("All {0} pages on this screen selected"),
-                'ALL_IN_LISTING': _("All pages in listing selected"),
+        "BULK_ACTIONS": {
+            "PAGE": {
+                "SINGULAR": _("1 page selected"),
+                "PLURAL": _("{0} pages selected"),
+                "ALL": _("All {0} pages on this screen selected"),
+                "ALL_IN_LISTING": _("All pages in listing selected"),
             },
-            'DOCUMENT': {
-                'SINGULAR': _('1 document selected'),
-                'PLURAL': _("{0} documents selected"),
-                'ALL': _("All {0} documents on this screen selected"),
-                'ALL_IN_LISTING': _("All documents in listing selected"),
+            "DOCUMENT": {
+                "SINGULAR": _("1 document selected"),
+                "PLURAL": _("{0} documents selected"),
+                "ALL": _("All {0} documents on this screen selected"),
+                "ALL_IN_LISTING": _("All documents in listing selected"),
             },
-            'IMAGE': {
-                'SINGULAR': _('1 image selected'),
-                'PLURAL': _("{0} images selected"),
-                'ALL': _("All {0} images on this screen selected"),
-                'ALL_IN_LISTING': _("All images in listing selected"),
+            "IMAGE": {
+                "SINGULAR": _("1 image selected"),
+                "PLURAL": _("{0} images selected"),
+                "ALL": _("All {0} images on this screen selected"),
+                "ALL_IN_LISTING": _("All images in listing selected"),
             },
-            'USER': {
-                'SINGULAR': _('1 user selected'),
-                'PLURAL': _("{0} users selected"),
-                'ALL': _("All {0} users on this screen selected"),
-                'ALL_IN_LISTING': _("All users in listing selected"),
+            "USER": {
+                "SINGULAR": _("1 user selected"),
+                "PLURAL": _("{0} users selected"),
+                "ALL": _("All {0} users on this screen selected"),
+                "ALL_IN_LISTING": _("All users in listing selected"),
             },
-            'ITEM': {
-                'SINGULAR': _('1 item selected'),
-                'PLURAL': _("{0} items selected"),
-                'ALL': _("All {0} items on this screen selected"),
-                'ALL_IN_LISTING': _("All items in listing selected"),
+            "ITEM": {
+                "SINGULAR": _("1 item selected"),
+                "PLURAL": _("{0} items selected"),
+                "ALL": _("All {0} items on this screen selected"),
+                "ALL_IN_LISTING": _("All items in listing selected"),
             },
         },
     }
 
 
 def get_available_admin_languages():
-    return getattr(settings, 'WAGTAILADMIN_PERMITTED_LANGUAGES', WAGTAILADMIN_PROVIDED_LANGUAGES)
+    return getattr(
+        settings, "WAGTAILADMIN_PERMITTED_LANGUAGES", WAGTAILADMIN_PROVIDED_LANGUAGES
+    )
 
 
 def get_available_admin_time_zones():
     if not settings.USE_TZ:
         return []
 
-    return getattr(settings, 'WAGTAIL_USER_TIME_ZONES', pytz.common_timezones)
+    return getattr(settings, "WAGTAIL_USER_TIME_ZONES", pytz.common_timezones)

+ 121 - 76
wagtail/admin/mail.py

@@ -12,12 +12,12 @@ from wagtail.core.models import GroupApprovalTask, TaskState, WorkflowState
 from wagtail.core.utils import camelcase_to_underscore
 from wagtail.users.models import UserProfile
 
-
-logger = logging.getLogger('wagtail.admin')
+logger = logging.getLogger("wagtail.admin")
 
 
 class OpenedConnection:
     """Context manager for mail connections to ensure they are closed when manually opened"""
+
     def __init__(self, connection):
         self.connection = connection
 
@@ -36,40 +36,46 @@ def send_mail(subject, message, recipient_list, from_email=None, **kwargs):
     Custom from_email handling and special Auto-Submitted header.
     """
     if not from_email:
-        if hasattr(settings, 'WAGTAILADMIN_NOTIFICATION_FROM_EMAIL'):
+        if hasattr(settings, "WAGTAILADMIN_NOTIFICATION_FROM_EMAIL"):
             from_email = settings.WAGTAILADMIN_NOTIFICATION_FROM_EMAIL
-        elif hasattr(settings, 'DEFAULT_FROM_EMAIL'):
+        elif hasattr(settings, "DEFAULT_FROM_EMAIL"):
             from_email = settings.DEFAULT_FROM_EMAIL
         else:
             # We are no longer using the term `webmaster` except in this case, where we continue to match Django's default: https://github.com/django/django/blob/stable/3.2.x/django/conf/global_settings.py#L223
-            from_email = 'webmaster@localhost'
+            from_email = "webmaster@localhost"
 
-    connection = kwargs.get('connection', False) or get_connection(
-        username=kwargs.get('auth_user', None),
-        password=kwargs.get('auth_password', None),
-        fail_silently=kwargs.get('fail_silently', None),
+    connection = kwargs.get("connection", False) or get_connection(
+        username=kwargs.get("auth_user", None),
+        password=kwargs.get("auth_password", None),
+        fail_silently=kwargs.get("fail_silently", None),
     )
     multi_alt_kwargs = {
-        'connection': connection,
-        'headers': {
-            'Auto-Submitted': 'auto-generated',
-        }
+        "connection": connection,
+        "headers": {
+            "Auto-Submitted": "auto-generated",
+        },
     }
-    mail = EmailMultiAlternatives(subject, message, from_email, recipient_list, **multi_alt_kwargs)
-    html_message = kwargs.get('html_message', None)
+    mail = EmailMultiAlternatives(
+        subject, message, from_email, recipient_list, **multi_alt_kwargs
+    )
+    html_message = kwargs.get("html_message", None)
     if html_message:
-        mail.attach_alternative(html_message, 'text/html')
+        mail.attach_alternative(html_message, "text/html")
 
     return mail.send()
 
 
 def send_moderation_notification(revision, notification, excluded_user=None):
     # Get list of recipients
-    if notification == 'submitted':
+    if notification == "submitted":
         # Get list of publishers
-        include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True)
-        recipient_users = users_with_page_permission(revision.page, 'publish', include_superusers)
-    elif notification in ['rejected', 'approved']:
+        include_superusers = getattr(
+            settings, "WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS", True
+        )
+        recipient_users = users_with_page_permission(
+            revision.page, "publish", include_superusers
+        )
+    elif notification in ["rejected", "approved"]:
         # Get submitter
         recipient_users = [revision.user]
     else:
@@ -78,16 +84,18 @@ def send_moderation_notification(revision, notification, excluded_user=None):
     if excluded_user:
         recipient_users = [user for user in recipient_users if user != excluded_user]
 
-    return send_notification(recipient_users, notification, {'revision': revision})
+    return send_notification(recipient_users, notification, {"revision": revision})
 
 
 def send_notification(recipient_users, notification, extra_context):
     # Get list of email addresses
     email_recipients = [
-        recipient for recipient in recipient_users
-        if recipient.is_active and recipient.email and getattr(
-            UserProfile.get_for_user(recipient),
-            notification + '_notifications'
+        recipient
+        for recipient in recipient_users
+        if recipient.is_active
+        and recipient.email
+        and getattr(
+            UserProfile.get_for_user(recipient), notification + "_notifications"
         )
     ]
 
@@ -96,9 +104,9 @@ def send_notification(recipient_users, notification, extra_context):
         return True
 
     # Get template
-    template_subject = 'wagtailadmin/notifications/' + notification + '_subject.txt'
-    template_text = 'wagtailadmin/notifications/' + notification + '.txt'
-    template_html = 'wagtailadmin/notifications/' + notification + '.html'
+    template_subject = "wagtailadmin/notifications/" + notification + "_subject.txt"
+    template_text = "wagtailadmin/notifications/" + notification + ".txt"
+    template_html = "wagtailadmin/notifications/" + notification + ".html"
 
     # Common context to template
     context = {
@@ -124,16 +132,23 @@ def send_notification(recipient_users, notification, extra_context):
                     email_content = render_to_string(template_text, context).strip()
 
                 kwargs = {}
-                if getattr(settings, 'WAGTAILADMIN_NOTIFICATION_USE_HTML', False):
-                    kwargs['html_message'] = render_to_string(template_html, context)
+                if getattr(settings, "WAGTAILADMIN_NOTIFICATION_USE_HTML", False):
+                    kwargs["html_message"] = render_to_string(template_html, context)
 
                 # Send email
-                send_mail(email_subject, email_content, [recipient.email], connection=open_connection, **kwargs)
+                send_mail(
+                    email_subject,
+                    email_content,
+                    [recipient.email],
+                    connection=open_connection,
+                    **kwargs,
+                )
                 sent_count += 1
             except Exception:
                 logger.exception(
                     "Failed to send notification email '%s' to %s",
-                    email_subject, recipient.email
+                    email_subject,
+                    recipient.email,
                 )
 
     return sent_count == len(email_recipients)
@@ -141,10 +156,10 @@ def send_notification(recipient_users, notification, extra_context):
 
 class Notifier:
     """Generic class for sending event notifications: callable, intended to be connected to a signal to send
-    notifications using rendered templates. """
+    notifications using rendered templates."""
 
-    notification = ''
-    template_directory = 'wagtailadmin/notifications/'
+    notification = ""
+    template_directory = "wagtailadmin/notifications/"
 
     def __init__(self, valid_classes):
         # the classes of the calling instance that the notifier can handle
@@ -159,19 +174,19 @@ class Notifier:
         return set()
 
     def get_template_base_prefix(self, instance, **kwargs):
-        return camelcase_to_underscore(type(instance).__name__) + '_'
+        return camelcase_to_underscore(type(instance).__name__) + "_"
 
     def get_context(self, instance, **kwargs):
-        return {'settings': settings}
+        return {"settings": settings}
 
     def get_template_set(self, instance, **kwargs):
         """Return a dictionary of template paths for the templates: by default, a text message"""
         template_base = self.get_template_base_prefix(instance) + self.notification
 
-        template_text = self.template_directory + template_base + '.txt'
+        template_text = self.template_directory + template_base + ".txt"
 
         return {
-            'text': template_text,
+            "text": template_text,
         }
 
     def send_notifications(self, template_set, context, recipients, **kwargs):
@@ -208,10 +223,13 @@ class EmailNotificationMixin:
         """Filters notification recipients to those allowing the notification type on their UserProfile, and those
         with an email address"""
         return {
-            recipient for recipient in self.get_recipient_users(instance, **kwargs)
-            if recipient.is_active and recipient.email and getattr(
+            recipient
+            for recipient in self.get_recipient_users(instance, **kwargs)
+            if recipient.is_active
+            and recipient.email
+            and getattr(
                 UserProfile.get_for_user(recipient),
-                self.notification + '_notifications'
+                self.notification + "_notifications",
             )
         }
 
@@ -220,14 +238,14 @@ class EmailNotificationMixin:
         alternatives"""
         template_base = self.get_template_base_prefix(instance) + self.notification
 
-        template_subject = self.template_directory + template_base + '_subject.txt'
-        template_text = self.template_directory + template_base + '.txt'
-        template_html = self.template_directory + template_base + '.html'
+        template_subject = self.template_directory + template_base + "_subject.txt"
+        template_text = self.template_directory + template_base + ".txt"
+        template_html = self.template_directory + template_base + ".html"
 
         return {
-            'subject': template_subject,
-            'text': template_text,
-            'html': template_html,
+            "subject": template_subject,
+            "text": template_text,
+            "html": template_html,
         }
 
     def send_emails(self, template_set, context, recipients, **kwargs):
@@ -245,22 +263,39 @@ class EmailNotificationMixin:
                         context["user"] = recipient
 
                         # Translate text to the recipient language settings
-                        with override(recipient.wagtail_userprofile.get_preferred_language()):
+                        with override(
+                            recipient.wagtail_userprofile.get_preferred_language()
+                        ):
                             # Get email subject and content
-                            email_subject = render_to_string(template_set['subject'], context).strip()
-                            email_content = render_to_string(template_set['text'], context).strip()
+                            email_subject = render_to_string(
+                                template_set["subject"], context
+                            ).strip()
+                            email_content = render_to_string(
+                                template_set["text"], context
+                            ).strip()
 
                         kwargs = {}
-                        if getattr(settings, 'WAGTAILADMIN_NOTIFICATION_USE_HTML', False):
-                            kwargs['html_message'] = render_to_string(template_set['html'], context)
+                        if getattr(
+                            settings, "WAGTAILADMIN_NOTIFICATION_USE_HTML", False
+                        ):
+                            kwargs["html_message"] = render_to_string(
+                                template_set["html"], context
+                            )
 
                         # Send email
-                        send_mail(email_subject, email_content, [recipient.email], connection=open_connection, **kwargs)
+                        send_mail(
+                            email_subject,
+                            email_content,
+                            [recipient.email],
+                            connection=open_connection,
+                            **kwargs,
+                        )
                         sent_count += 1
                     except Exception:
                         logger.exception(
                             "Failed to send notification email '%s' to %s",
-                            email_subject, recipient.email
+                            email_subject,
+                            recipient.email,
                         )
         except (TimeoutError, ConnectionError):
             logger.exception("Mail connection error, notification sending skipped")
@@ -279,18 +314,18 @@ class BaseWorkflowStateEmailNotifier(EmailNotificationMixin, Notifier):
 
     def get_context(self, workflow_state, **kwargs):
         context = super().get_context(workflow_state, **kwargs)
-        context['page'] = workflow_state.page
-        context['workflow'] = workflow_state.workflow
+        context["page"] = workflow_state.page
+        context["workflow"] = workflow_state.workflow
         return context
 
 
 class WorkflowStateApprovalEmailNotifier(BaseWorkflowStateEmailNotifier):
     """A notifier to send email updates for WorkflowState approval events"""
 
-    notification = 'approved'
+    notification = "approved"
 
     def get_recipient_users(self, workflow_state, **kwargs):
-        triggering_user = kwargs.get('user', None)
+        triggering_user = kwargs.get("user", None)
         recipients = {}
         requested_by = workflow_state.requested_by
         if requested_by != triggering_user:
@@ -302,10 +337,10 @@ class WorkflowStateApprovalEmailNotifier(BaseWorkflowStateEmailNotifier):
 class WorkflowStateRejectionEmailNotifier(BaseWorkflowStateEmailNotifier):
     """A notifier to send email updates for WorkflowState rejection events"""
 
-    notification = 'rejected'
+    notification = "rejected"
 
     def get_recipient_users(self, workflow_state, **kwargs):
-        triggering_user = kwargs.get('user', None)
+        triggering_user = kwargs.get("user", None)
         recipients = {}
         requested_by = workflow_state.requested_by
         if requested_by != triggering_user:
@@ -316,21 +351,23 @@ class WorkflowStateRejectionEmailNotifier(BaseWorkflowStateEmailNotifier):
     def get_context(self, workflow_state, **kwargs):
         context = super().get_context(workflow_state, **kwargs)
         task_state = workflow_state.current_task_state.specific
-        context['task'] = task_state.task
-        context['task_state'] = task_state
-        context['comment'] = task_state.get_comment()
+        context["task"] = task_state.task
+        context["task_state"] = task_state
+        context["comment"] = task_state.get_comment()
         return context
 
 
 class WorkflowStateSubmissionEmailNotifier(BaseWorkflowStateEmailNotifier):
     """A notifier to send email updates for WorkflowState submission events"""
 
-    notification = 'submitted'
+    notification = "submitted"
 
     def get_recipient_users(self, workflow_state, **kwargs):
-        triggering_user = kwargs.get('user', None)
+        triggering_user = kwargs.get("user", None)
         recipients = get_user_model().objects.none()
-        include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True)
+        include_superusers = getattr(
+            settings, "WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS", True
+        )
         if include_superusers:
             recipients = get_user_model().objects.filter(is_superuser=True)
         if triggering_user:
@@ -340,7 +377,7 @@ class WorkflowStateSubmissionEmailNotifier(BaseWorkflowStateEmailNotifier):
 
     def get_context(self, workflow_state, **kwargs):
         context = super().get_context(workflow_state, **kwargs)
-        context['requested_by'] = workflow_state.requested_by
+        context["requested_by"] = workflow_state.requested_by
         return context
 
 
@@ -351,24 +388,30 @@ class BaseGroupApprovalTaskStateEmailNotifier(EmailNotificationMixin, Notifier):
         super().__init__((TaskState,))
 
     def can_handle(self, instance, **kwargs):
-        if super().can_handle(instance, **kwargs) and isinstance(instance.task.specific, GroupApprovalTask):
+        if super().can_handle(instance, **kwargs) and isinstance(
+            instance.task.specific, GroupApprovalTask
+        ):
             return True
         return False
 
     def get_context(self, task_state, **kwargs):
         context = super().get_context(task_state, **kwargs)
-        context['page'] = task_state.workflow_state.page
-        context['task'] = task_state.task.specific
+        context["page"] = task_state.workflow_state.page
+        context["task"] = task_state.task.specific
         return context
 
     def get_recipient_users(self, task_state, **kwargs):
-        triggering_user = kwargs.get('user', None)
+        triggering_user = kwargs.get("user", None)
 
-        group_members = get_user_model().objects.filter(groups__in=task_state.task.specific.groups.all())
+        group_members = get_user_model().objects.filter(
+            groups__in=task_state.task.specific.groups.all()
+        )
 
         recipients = group_members
 
-        include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True)
+        include_superusers = getattr(
+            settings, "WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS", True
+        )
         if include_superusers:
             superusers = get_user_model().objects.filter(is_superuser=True)
             recipients = recipients | superusers
@@ -379,7 +422,9 @@ class BaseGroupApprovalTaskStateEmailNotifier(EmailNotificationMixin, Notifier):
         return recipients
 
 
-class GroupApprovalTaskStateSubmissionEmailNotifier(BaseGroupApprovalTaskStateEmailNotifier):
+class GroupApprovalTaskStateSubmissionEmailNotifier(
+    BaseGroupApprovalTaskStateEmailNotifier
+):
     """A notifier to send email updates for GroupApprovalTask submission events"""
 
-    notification = 'submitted'
+    notification = "submitted"

+ 52 - 22
wagtail/admin/menu.py

@@ -10,14 +10,16 @@ from wagtail.core.utils import cautious_slugify
 
 
 class MenuItem(metaclass=MediaDefiningClass):
-    template = 'wagtailadmin/shared/menu_item.html'
+    template = "wagtailadmin/shared/menu_item.html"
 
-    def __init__(self, label, url, name=None, classnames='', icon_name='', attrs=None, order=1000):
+    def __init__(
+        self, label, url, name=None, classnames="", icon_name="", attrs=None, order=1000
+    ):
         self.label = label
         self.url = url
         self.classnames = classnames
         self.icon_name = icon_name
-        self.name = (name or cautious_slugify(str(label)))
+        self.name = name or cautious_slugify(str(label))
         self.order = order
 
         if attrs:
@@ -38,13 +40,13 @@ class MenuItem(metaclass=MediaDefiningClass):
     def get_context(self, request):
         """Defines context for the template, overridable to use more data"""
         return {
-            'name': self.name,
-            'url': self.url,
-            'classnames': self.classnames,
-            'icon_name': self.icon_name,
-            'attr_string': self.attr_string,
-            'label': self.label,
-            'active': self.is_active(request)
+            "name": self.name,
+            "url": self.url,
+            "classnames": self.classnames,
+            "icon_name": self.icon_name,
+            "attr_string": self.attr_string,
+            "label": self.label,
+            "active": self.is_active(request),
         }
 
     def render_html(self, request):
@@ -52,7 +54,13 @@ class MenuItem(metaclass=MediaDefiningClass):
         return render_to_string(self.template, context, request=request)
 
     def render_component(self, request):
-        return LinkMenuItemComponent(self.name, self.label, self.url, icon_name=self.icon_name, classnames=self.classnames)
+        return LinkMenuItemComponent(
+            self.name,
+            self.label,
+            self.url,
+            icon_name=self.icon_name,
+            classnames=self.classnames,
+        )
 
 
 class Menu:
@@ -68,7 +76,9 @@ class Menu:
     @property
     def registered_menu_items(self):
         if self._registered_menu_items is None:
-            self._registered_menu_items = [fn() for fn in hooks.get_hooks(self.register_hook_name)]
+            self._registered_menu_items = [
+                fn() for fn in hooks.get_hooks(self.register_hook_name)
+            ]
         return self._registered_menu_items
 
     def menu_items_for_request(self, request):
@@ -82,7 +92,11 @@ class Menu:
         return items
 
     def active_menu_items(self, request):
-        return [item for item in self.menu_items_for_request(request) if item.is_active(request)]
+        return [
+            item
+            for item in self.menu_items_for_request(request)
+            if item.is_active(request)
+        ]
 
     @property
     def media(self):
@@ -96,7 +110,7 @@ class Menu:
         rendered_menu_items = []
         for item in sorted(menu_items, key=lambda i: i.order):
             rendered_menu_items.append(item.render_html(request))
-        return mark_safe(''.join(rendered_menu_items))
+        return mark_safe("".join(rendered_menu_items))
 
     def render_component(self, request):
         menu_items = self.menu_items_for_request(request)
@@ -107,12 +121,13 @@ class Menu:
 
 
 class SubmenuMenuItem(MenuItem):
-    template = 'wagtailadmin/shared/menu_submenu_item.html'
+    template = "wagtailadmin/shared/menu_submenu_item.html"
 
     """A MenuItem which wraps an inner Menu object"""
+
     def __init__(self, label, menu, **kwargs):
         self.menu = menu
-        super().__init__(label, '#', **kwargs)
+        super().__init__(label, "#", **kwargs)
 
     def is_shown(self, request):
         # show the submenu if one or more of its children is shown
@@ -123,12 +138,18 @@ class SubmenuMenuItem(MenuItem):
 
     def get_context(self, request):
         context = super().get_context(request)
-        context['menu_html'] = self.menu.render_html(request)
-        context['request'] = request
+        context["menu_html"] = self.menu.render_html(request)
+        context["request"] = request
         return context
 
     def render_component(self, request):
-        return SubMenuItemComponent(self.name, self.label, self.menu.render_component(request), icon_name=self.icon_name, classnames=self.classnames)
+        return SubMenuItemComponent(
+            self.name,
+            self.label,
+            self.menu.render_component(request),
+            icon_name=self.icon_name,
+            classnames=self.classnames,
+        )
 
 
 class AdminOnlyMenuItem(MenuItem):
@@ -138,6 +159,15 @@ class AdminOnlyMenuItem(MenuItem):
         return request.user.is_superuser
 
 
-admin_menu = Menu(register_hook_name='register_admin_menu_item', construct_hook_name='construct_main_menu')
-settings_menu = Menu(register_hook_name='register_settings_menu_item', construct_hook_name='construct_settings_menu')
-reports_menu = Menu(register_hook_name='register_reports_menu_item', construct_hook_name='construct_reports_menu')
+admin_menu = Menu(
+    register_hook_name="register_admin_menu_item",
+    construct_hook_name="construct_main_menu",
+)
+settings_menu = Menu(
+    register_hook_name="register_settings_menu_item",
+    construct_hook_name="construct_settings_menu",
+)
+reports_menu = Menu(
+    register_hook_name="register_reports_menu_item",
+    construct_hook_name="construct_reports_menu",
+)

+ 19 - 16
wagtail/admin/messages.py

@@ -4,31 +4,34 @@ from django.template.loader import render_to_string
 from django.utils.html import format_html, format_html_join
 
 
-def render(message, buttons, detail=''):
-    return render_to_string('wagtailadmin/shared/messages.html', {
-        'message': message,
-        'buttons': buttons,
-        'detail': detail,
-    })
-
-
-def debug(request, message, buttons=None, extra_tags=''):
+def render(message, buttons, detail=""):
+    return render_to_string(
+        "wagtailadmin/shared/messages.html",
+        {
+            "message": message,
+            "buttons": buttons,
+            "detail": detail,
+        },
+    )
+
+
+def debug(request, message, buttons=None, extra_tags=""):
     return messages.debug(request, render(message, buttons), extra_tags=extra_tags)
 
 
-def info(request, message, buttons=None, extra_tags=''):
+def info(request, message, buttons=None, extra_tags=""):
     return messages.info(request, render(message, buttons), extra_tags=extra_tags)
 
 
-def success(request, message, buttons=None, extra_tags=''):
+def success(request, message, buttons=None, extra_tags=""):
     return messages.success(request, render(message, buttons), extra_tags=extra_tags)
 
 
-def warning(request, message, buttons=None, extra_tags=''):
+def warning(request, message, buttons=None, extra_tags=""):
     return messages.warning(request, render(message, buttons), extra_tags=extra_tags)
 
 
-def error(request, message, buttons=None, extra_tags=''):
+def error(request, message, buttons=None, extra_tags=""):
     return messages.error(request, render(message, buttons), extra_tags=extra_tags)
 
 
@@ -36,13 +39,13 @@ def validation_error(request, message, form, buttons=None):
     if not form.non_field_errors():
         # just output the generic "there were validation errors" message, and leave
         # the per-field highlighting to do the rest
-        detail = ''
+        detail = ""
     else:
         # display the full list of field and non-field validation errors
         all_errors = []
         for field_name, errors in form.errors.items():
             if field_name == NON_FIELD_ERRORS:
-                prefix = ''
+                prefix = ""
             else:
                 try:
                     field_label = form[field_name].label
@@ -53,7 +56,7 @@ def validation_error(request, message, form, buttons=None):
             for error in errors:
                 all_errors.append(prefix + error)
 
-        errors_html = format_html_join('\n', '<li>{}</li>', ((e,) for e in all_errors))
+        errors_html = format_html_join("\n", "<li>{}</li>", ((e,) for e in all_errors))
         detail = format_html("""<ul class="errorlist">{}</ul>""", errors_html)
 
     return messages.error(request, render(message, buttons, detail=detail))

+ 16 - 15
wagtail/admin/migrations/0001_create_admin_access_permissions.py

@@ -3,40 +3,39 @@ from django.db import migrations
 
 
 def create_admin_access_permissions(apps, schema_editor):
-    ContentType = apps.get_model('contenttypes.ContentType')
-    Permission = apps.get_model('auth.Permission')
-    Group = apps.get_model('auth.Group')
+    ContentType = apps.get_model("contenttypes.ContentType")
+    Permission = apps.get_model("auth.Permission")
+    Group = apps.get_model("auth.Group")
 
     # Add a content type to hang the 'can access Wagtail admin' permission off
     wagtailadmin_content_type, created = ContentType.objects.get_or_create(
-        app_label='wagtailadmin',
-        model='admin'
+        app_label="wagtailadmin", model="admin"
     )
 
     # Create admin permission
     admin_permission, created = Permission.objects.get_or_create(
         content_type=wagtailadmin_content_type,
-        codename='access_admin',
-        name='Can access Wagtail admin'
+        codename="access_admin",
+        name="Can access Wagtail admin",
     )
 
     # Assign it to Editors and Moderators groups
-    for group in Group.objects.filter(name__in=['Editors', 'Moderators']):
+    for group in Group.objects.filter(name__in=["Editors", "Moderators"]):
         group.permissions.add(admin_permission)
 
 
 def remove_admin_access_permissions(apps, schema_editor):
     """Reverse the above additions of permissions."""
-    ContentType = apps.get_model('contenttypes.ContentType')
-    Permission = apps.get_model('auth.Permission')
+    ContentType = apps.get_model("contenttypes.ContentType")
+    Permission = apps.get_model("auth.Permission")
     wagtailadmin_content_type = ContentType.objects.get(
-        app_label='wagtailadmin',
-        model='admin',
+        app_label="wagtailadmin",
+        model="admin",
     )
     # This cascades to Group
     Permission.objects.filter(
         content_type=wagtailadmin_content_type,
-        codename='access_admin',
+        codename="access_admin",
     ).delete()
 
 
@@ -45,9 +44,11 @@ class Migration(migrations.Migration):
     dependencies = [
         # We cannot apply and unapply this migration unless GroupCollectionPermission
         # is created. #2529
-        ('wagtailcore', '0026_group_collection_permission'),
+        ("wagtailcore", "0026_group_collection_permission"),
     ]
 
     operations = [
-        migrations.RunPython(create_admin_access_permissions, remove_admin_access_permissions),
+        migrations.RunPython(
+            create_admin_access_permissions, remove_admin_access_permissions
+        ),
     ]

+ 14 - 6
wagtail/admin/migrations/0002_admin.py

@@ -8,19 +8,27 @@ class Migration(migrations.Migration):
     initial = True
 
     dependencies = [
-        ('wagtailadmin', '0001_create_admin_access_permissions'),
+        ("wagtailadmin", "0001_create_admin_access_permissions"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='Admin',
+            name="Admin",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
             ],
             options={
-                'permissions': [('access_admin', 'Can access Wagtail admin')],
-                'managed': False,
-                'default_permissions': [],
+                "permissions": [("access_admin", "Can access Wagtail admin")],
+                "managed": False,
+                "default_permissions": [],
             },
         ),
     ]

+ 14 - 6
wagtail/admin/migrations/0003_admin_managed.py

@@ -6,19 +6,27 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('wagtailadmin', '0002_admin'),
+        ("wagtailadmin", "0002_admin"),
     ]
 
     operations = [
-        migrations.DeleteModel(name='Admin'),
+        migrations.DeleteModel(name="Admin"),
         migrations.CreateModel(
-            name='Admin',
+            name="Admin",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
             ],
             options={
-                'permissions': [('access_admin', 'Can access Wagtail admin')],
-                'default_permissions': [],
+                "permissions": [("access_admin", "Can access Wagtail admin")],
+                "default_permissions": [],
             },
         ),
     ]

+ 10 - 4
wagtail/admin/modal_workflow.py

@@ -2,19 +2,25 @@ from django.http import JsonResponse
 from django.template.loader import render_to_string
 
 
-def render_modal_workflow(request, html_template, js_template=None, template_vars=None, json_data=None):
-    """"
+def render_modal_workflow(
+    request, html_template, js_template=None, template_vars=None, json_data=None
+):
+    """ "
     Render a response consisting of an HTML chunk and a JS onload chunk
     in the format required by the modal-workflow framework.
     """
     if js_template:
-        raise TypeError("Passing a js_template argument to render_modal_workflow is no longer supported")
+        raise TypeError(
+            "Passing a js_template argument to render_modal_workflow is no longer supported"
+        )
 
     # construct response as JSON
     response = {}
 
     if html_template:
-        response['html'] = render_to_string(html_template, template_vars or {}, request=request)
+        response["html"] = render_to_string(
+            html_template, template_vars or {}, request=request
+        )
 
     if json_data:
         response.update(json_data)

+ 22 - 16
wagtail/admin/models.py

@@ -16,9 +16,11 @@ from wagtail.core.models import Page
 # management command.
 class Admin(Model):
     class Meta:
-        default_permissions = []  # don't create the default add / change / delete / view perms
+        default_permissions = (
+            []
+        )  # don't create the default add / change / delete / view perms
         permissions = [
-            ('access_admin', "Can access Wagtail admin"),
+            ("access_admin", "Can access Wagtail admin"),
         ]
 
 
@@ -28,28 +30,32 @@ def get_object_usage(obj):
     pages = Page.objects.none()
 
     # get all the relation objects for obj
-    relations = [f for f in type(obj)._meta.get_fields(include_hidden=True)
-                 if (f.one_to_many or f.one_to_one) and f.auto_created]
+    relations = [
+        f
+        for f in type(obj)._meta.get_fields(include_hidden=True)
+        if (f.one_to_many or f.one_to_one) and f.auto_created
+    ]
     for relation in relations:
         related_model = relation.related_model
 
         # if the relation is between obj and a page, get the page
         if issubclass(related_model, Page):
             pages |= Page.objects.filter(
-                id__in=related_model._base_manager.filter(**{
-                    relation.field.name: obj.id
-                }).values_list('id', flat=True)
+                id__in=related_model._base_manager.filter(
+                    **{relation.field.name: obj.id}
+                ).values_list("id", flat=True)
             )
         else:
             # if the relation is between obj and an object that has a page as a
             # property, return the page
             for f in related_model._meta.fields:
-                if isinstance(f, ParentalKey) and issubclass(f.remote_field.model, Page):
+                if isinstance(f, ParentalKey) and issubclass(
+                    f.remote_field.model, Page
+                ):
                     pages |= Page.objects.filter(
                         id__in=related_model._base_manager.filter(
-                            **{
-                                relation.field.name: obj.id
-                            }).values_list(f.attname, flat=True)
+                            **{relation.field.name: obj.id}
+                        ).values_list(f.attname, flat=True)
                     )
 
     return pages
@@ -58,8 +64,8 @@ def get_object_usage(obj):
 def popular_tags_for_model(model, count=10):
     """Return a queryset of the most frequently used tags used on this model class"""
     content_type = ContentType.objects.get_for_model(model)
-    return Tag.objects.filter(
-        taggit_taggeditem_items__content_type=content_type
-    ).annotate(
-        item_count=Count('taggit_taggeditem_items')
-    ).order_by('-item_count')[:count]
+    return (
+        Tag.objects.filter(taggit_taggeditem_items__content_type=content_type)
+        .annotate(item_count=Count("taggit_taggeditem_items"))
+        .order_by("-item_count")[:count]
+    )

+ 8 - 9
wagtail/admin/navigation.py

@@ -11,7 +11,7 @@ def get_pages_with_direct_explore_permission(user):
     else:
         return Page.objects.filter(
             group_permissions__group__in=user.groups.all(),
-            group_permissions__permission_type__in=['add', 'edit', 'publish', 'lock']
+            group_permissions__permission_type__in=["add", "edit", "publish", "lock"],
         )
 
 
@@ -20,10 +20,7 @@ def get_explorable_root_page(user):
     # has no permissions over any pages, this method will return None.
     pages = get_pages_with_direct_explore_permission(user)
     try:
-        root_page = pages.first_common_ancestor(
-            include_self=True,
-            strict=True
-        )
+        root_page = pages.first_common_ancestor(include_self=True, strict=True)
     except Page.DoesNotExist:
         root_page = None
 
@@ -38,9 +35,11 @@ def get_site_for_user(user):
         root_site = None
     real_site_name = None
     if root_site:
-        real_site_name = root_site.site_name if root_site.site_name else root_site.hostname
+        real_site_name = (
+            root_site.site_name if root_site.site_name else root_site.hostname
+        )
     return {
-        'root_page': root_page,
-        'root_site': root_site,
-        'site_name': real_site_name if real_site_name else settings.WAGTAIL_SITE_NAME,
+        "root_page": root_page,
+        "root_site": root_site,
+        "site_name": real_site_name if real_site_name else settings.WAGTAIL_SITE_NAME,
     }

+ 15 - 13
wagtail/admin/rich_text/__init__.py

@@ -3,35 +3,37 @@ from django.utils.module_loading import import_string
 
 from wagtail.admin.rich_text.editors.draftail import DraftailRichTextArea  # NOQA
 from wagtail.admin.rich_text.editors.hallo import (  # NOQA
-    HalloFormatPlugin, HalloHeadingPlugin, HalloListPlugin, HalloPlugin, HalloRichTextArea)
-
+    HalloFormatPlugin,
+    HalloHeadingPlugin,
+    HalloListPlugin,
+    HalloPlugin,
+    HalloRichTextArea,
+)
 
 DEFAULT_RICH_TEXT_EDITORS = {
-    'default': {
-        'WIDGET': 'wagtail.admin.rich_text.DraftailRichTextArea'
-    }
+    "default": {"WIDGET": "wagtail.admin.rich_text.DraftailRichTextArea"}
 }
 
 
-def get_rich_text_editor_widget(name='default', features=None):
+def get_rich_text_editor_widget(name="default", features=None):
     editor_settings = DEFAULT_RICH_TEXT_EDITORS.copy()
-    editor_settings.update(getattr(settings, 'WAGTAILADMIN_RICH_TEXT_EDITORS', {}))
+    editor_settings.update(getattr(settings, "WAGTAILADMIN_RICH_TEXT_EDITORS", {}))
 
     editor = editor_settings[name]
-    options = editor.get('OPTIONS', None)
+    options = editor.get("OPTIONS", None)
 
     if features is None and options is not None:
         # fall back on 'features' list within OPTIONS, if any
-        features = options.get('features', None)
+        features = options.get("features", None)
 
-    cls = import_string(editor['WIDGET'])
+    cls = import_string(editor["WIDGET"])
 
     kwargs = {}
 
     if options is not None:
-        kwargs['options'] = options
+        kwargs["options"] = options
 
-    if getattr(cls, 'accepts_features', False):
-        kwargs['features'] = features
+    if getattr(cls, "accepts_features", False):
+        kwargs["features"] = features
 
     return cls(**kwargs)

+ 55 - 38
wagtail/admin/rich_text/converters/contentstate.py

@@ -7,7 +7,9 @@ from draftjs_exporter.dom import DOM
 from draftjs_exporter.html import HTML as HTMLExporter
 
 from wagtail.admin.rich_text.converters.html_to_contentstate import (
-    BLOCK_KEY_NAME, HtmlToContentStateHandler)
+    BLOCK_KEY_NAME,
+    HtmlToContentStateHandler,
+)
 from wagtail.core.rich_text import features as feature_registry
 from wagtail.core.whitelist import check_url
 
@@ -16,41 +18,41 @@ def link_entity(props):
     """
     <a linktype="page" id="1">internal page link</a>
     """
-    id_ = props.get('id')
+    id_ = props.get("id")
     link_props = {}
 
     if id_ is not None:
-        link_props['linktype'] = 'page'
-        link_props['id'] = id_
+        link_props["linktype"] = "page"
+        link_props["id"] = id_
     else:
-        link_props['href'] = check_url(props.get('url'))
+        link_props["href"] = check_url(props.get("url"))
 
-    return DOM.create_element('a', link_props, props['children'])
+    return DOM.create_element("a", link_props, props["children"])
 
 
 def br(props):
-    if props['block']['type'] == 'code-block':
-        return props['children']
+    if props["block"]["type"] == "code-block":
+        return props["children"]
 
-    return DOM.create_element('br')
+    return DOM.create_element("br")
 
 
 def block_fallback(props):
-    type_ = props['block']['type']
+    type_ = props["block"]["type"]
     logging.error('Missing config for "%s". Deleting block.' % type_)
     return None
 
 
 def entity_fallback(props):
-    type_ = props['entity']['type']
+    type_ = props["entity"]["type"]
     logging.warning('Missing config for "%s". Deleting entity' % type_)
     return None
 
 
 def style_fallback(props):
-    type_ = props['inline_style_range']['style']
+    type_ = props["inline_style_range"]["style"]
     logging.warning('Missing config for "%s". Deleting style.' % type_)
-    return props['children']
+    return props["children"]
 
 
 def persist_key_for_block(config):
@@ -58,9 +60,13 @@ def persist_key_for_block(config):
     # block key in a data attribute
     if isinstance(config, dict):
         # Wrapper elements don't retain a key - we can keep them in the config as-is
-        new_config = {key: value for key, value in config.items() if key in {'wrapper', 'wrapper_props'}}
-        element = config.get('element')
-        element_props = config.get('props', {})
+        new_config = {
+            key: value
+            for key, value in config.items()
+            if key in {"wrapper", "wrapper_props"}
+        }
+        element = config.get("element")
+        element_props = config.get("props", {})
     else:
         # The config is either a simple string element name, or a function
         new_config = {}
@@ -68,7 +74,7 @@ def persist_key_for_block(config):
         element = config
 
     def element_with_uuid(props):
-        added_props = {BLOCK_KEY_NAME: props['block'].get('key')}
+        added_props = {BLOCK_KEY_NAME: props["block"].get("key")}
         try:
             # See if the element is a function - if so, we can only run it and modify its return value to include the data attribute
             elt = element(props)
@@ -79,45 +85,54 @@ def persist_key_for_block(config):
             # Otherwise we can do the normal process of creating a DOM element with the right element type
             # and simply adding the data attribute to its props
             added_props.update(element_props)
-            return DOM.create_element(element, added_props, props['children'])
+            return DOM.create_element(element, added_props, props["children"])
 
-    new_config['element'] = element_with_uuid
+    new_config["element"] = element_with_uuid
     return new_config
 
 
-class ContentstateConverter():
+class ContentstateConverter:
     def __init__(self, features=None):
         self.features = features
         self.html_to_contentstate_handler = HtmlToContentStateHandler(features)
 
         exporter_config = {
-            'block_map': {
-                'unstyled': persist_key_for_block('p'),
-                'atomic': render_children,
-                'fallback': block_fallback,
+            "block_map": {
+                "unstyled": persist_key_for_block("p"),
+                "atomic": render_children,
+                "fallback": block_fallback,
             },
-            'style_map': {
-                'FALLBACK': style_fallback,
+            "style_map": {
+                "FALLBACK": style_fallback,
             },
-            'entity_decorators': {
-                'FALLBACK': entity_fallback,
+            "entity_decorators": {
+                "FALLBACK": entity_fallback,
             },
-            'composite_decorators': [
+            "composite_decorators": [
                 {
-                    'strategy': re.compile(r'\n'),
-                    'component': br,
+                    "strategy": re.compile(r"\n"),
+                    "component": br,
                 },
             ],
-            'engine': DOM.STRING,
+            "engine": DOM.STRING,
         }
 
         for feature in self.features:
-            rule = feature_registry.get_converter_rule('contentstate', feature)
+            rule = feature_registry.get_converter_rule("contentstate", feature)
             if rule is not None:
-                feature_config = rule['to_database_format']
-                exporter_config['block_map'].update({block_type: persist_key_for_block(config) for block_type, config in feature_config.get('block_map', {}).items()})
-                exporter_config['style_map'].update(feature_config.get('style_map', {}))
-                exporter_config['entity_decorators'].update(feature_config.get('entity_decorators', {}))
+                feature_config = rule["to_database_format"]
+                exporter_config["block_map"].update(
+                    {
+                        block_type: persist_key_for_block(config)
+                        for block_type, config in feature_config.get(
+                            "block_map", {}
+                        ).items()
+                    }
+                )
+                exporter_config["style_map"].update(feature_config.get("style_map", {}))
+                exporter_config["entity_decorators"].update(
+                    feature_config.get("entity_decorators", {})
+                )
 
         self.exporter = HTMLExporter(exporter_config)
 
@@ -126,7 +141,9 @@ class ContentstateConverter():
         self.html_to_contentstate_handler.feed(html)
         self.html_to_contentstate_handler.close()
 
-        return self.html_to_contentstate_handler.contentstate.as_json(indent=4, separators=(',', ': '))
+        return self.html_to_contentstate_handler.contentstate.as_json(
+            indent=4, separators=(",", ": ")
+        )
 
     def to_database_format(self, contentstate_json):
         return self.exporter.render(json.loads(contentstate_json))

+ 21 - 19
wagtail/admin/rich_text/converters/contentstate_models.py

@@ -2,7 +2,6 @@ import json
 import random
 import string
 
-
 ALPHANUM = string.ascii_lowercase + string.digits
 
 
@@ -11,18 +10,18 @@ class Block:
         self.type = typ
         self.depth = depth
         self.text = ""
-        self.key = key if key else ''.join(random.choice(ALPHANUM) for _ in range(5))
+        self.key = key if key else "".join(random.choice(ALPHANUM) for _ in range(5))
         self.inline_style_ranges = []
         self.entity_ranges = []
 
     def as_dict(self):
         return {
-            'key': self.key,
-            'type': self.type,
-            'depth': self.depth,
-            'text': self.text,
-            'inlineStyleRanges': [isr.as_dict() for isr in self.inline_style_ranges],
-            'entityRanges': [er.as_dict() for er in self.entity_ranges],
+            "key": self.key,
+            "type": self.type,
+            "depth": self.depth,
+            "text": self.text,
+            "inlineStyleRanges": [isr.as_dict() for isr in self.inline_style_ranges],
+            "entityRanges": [er.as_dict() for er in self.entity_ranges],
         }
 
 
@@ -34,9 +33,9 @@ class InlineStyleRange:
 
     def as_dict(self):
         return {
-            'offset': self.offset,
-            'length': self.length,
-            'style': self.style,
+            "offset": self.offset,
+            "length": self.length,
+            "style": self.style,
         }
 
 
@@ -48,9 +47,9 @@ class Entity:
 
     def as_dict(self):
         return {
-            'mutability': self.mutability,
-            'type': self.entity_type,
-            'data': self.data,
+            "mutability": self.mutability,
+            "type": self.entity_type,
+            "data": self.data,
         }
 
 
@@ -62,14 +61,15 @@ class EntityRange:
 
     def as_dict(self):
         return {
-            'key': self.key,
-            'offset': self.offset,
-            'length': self.length,
+            "key": self.key,
+            "offset": self.offset,
+            "length": self.length,
         }
 
 
 class ContentState:
     """Pythonic representation of a draft.js contentState structure"""
+
     def __init__(self):
         self.blocks = []
         self.entity_count = 0
@@ -83,8 +83,10 @@ class ContentState:
 
     def as_dict(self):
         return {
-            'blocks': [block.as_dict() for block in self.blocks],
-            'entityMap': {key: entity.as_dict() for (key, entity) in self.entity_map.items()},
+            "blocks": [block.as_dict() for block in self.blocks],
+            "entityMap": {
+                key: entity.as_dict() for (key, entity) in self.entity_map.items()
+            },
         }
 
     def as_json(self, **kwargs):

+ 28 - 22
wagtail/admin/rich_text/converters/editor_html.py

@@ -3,7 +3,11 @@ from django.utils.html import escape
 
 from wagtail.core.models import Page
 from wagtail.core.rich_text import features as feature_registry
-from wagtail.core.rich_text.rewriters import EmbedRewriter, LinkRewriter, MultiRuleRewriter
+from wagtail.core.rich_text.rewriters import (
+    EmbedRewriter,
+    LinkRewriter,
+    MultiRuleRewriter,
+)
 from wagtail.core.whitelist import Whitelister, allow_without_attributes
 
 
@@ -28,10 +32,10 @@ class LinkTypeRule:
 # Whitelist rules which are always active regardless of the rich text features that are enabled
 
 BASE_WHITELIST_RULES = {
-    '[document]': allow_without_attributes,
-    'p': allow_without_attributes,
-    'div': allow_without_attributes,
-    'br': allow_without_attributes,
+    "[document]": allow_without_attributes,
+    "p": allow_without_attributes,
+    "div": allow_without_attributes,
+    "br": allow_without_attributes,
 }
 
 
@@ -48,6 +52,7 @@ class DbWhitelister(Whitelister):
       determined by the handler for that type defined in link_handlers, while keeping the
       element content intact.
     """
+
     def __init__(self, converter_rules):
         self.converter_rules = converter_rules
         self.element_rules = BASE_WHITELIST_RULES.copy()
@@ -58,20 +63,22 @@ class DbWhitelister(Whitelister):
     @cached_property
     def embed_handlers(self):
         return {
-            rule.embed_type: rule.handler for rule in self.converter_rules
+            rule.embed_type: rule.handler
+            for rule in self.converter_rules
             if isinstance(rule, EmbedTypeRule)
         }
 
     @cached_property
     def link_handlers(self):
         return {
-            rule.link_type: rule.handler for rule in self.converter_rules
+            rule.link_type: rule.handler
+            for rule in self.converter_rules
             if isinstance(rule, LinkTypeRule)
         }
 
     def clean_tag_node(self, doc, tag):
-        if 'data-embedtype' in tag.attrs:
-            embed_type = tag['data-embedtype']
+        if "data-embedtype" in tag.attrs:
+            embed_type = tag["data-embedtype"]
             # fetch the appropriate embed handler for this embedtype
             try:
                 embed_handler = self.embed_handlers[embed_type]
@@ -81,17 +88,17 @@ class DbWhitelister(Whitelister):
                 return
 
             embed_attrs = embed_handler.get_db_attributes(tag)
-            embed_attrs['embedtype'] = embed_type
+            embed_attrs["embedtype"] = embed_type
 
-            embed_tag = doc.new_tag('embed', **embed_attrs)
+            embed_tag = doc.new_tag("embed", **embed_attrs)
             embed_tag.can_be_empty_element = True
             tag.replace_with(embed_tag)
-        elif tag.name == 'a' and 'data-linktype' in tag.attrs:
+        elif tag.name == "a" and "data-linktype" in tag.attrs:
             # first, whitelist the contents of this tag
             for child in tag.contents:
                 self.clean_node(doc, child)
 
-            link_type = tag['data-linktype']
+            link_type = tag["data-linktype"]
             try:
                 link_handler = self.link_handlers[link_type]
             except KeyError:
@@ -100,12 +107,12 @@ class DbWhitelister(Whitelister):
                 return
 
             link_attrs = link_handler.get_db_attributes(tag)
-            link_attrs['linktype'] = link_type
+            link_attrs["linktype"] = link_type
             tag.attrs.clear()
             tag.attrs.update(**link_attrs)
         else:
-            if tag.name == 'div':
-                tag.name = 'p'
+            if tag.name == "div":
+                tag.name = "p"
 
             super(DbWhitelister, self).clean_tag_node(doc, tag)
 
@@ -117,7 +124,7 @@ class EditorHTMLConverter:
 
         self.converter_rules = []
         for feature in features:
-            rule = feature_registry.get_converter_rule('editorhtml', feature)
+            rule = feature_registry.get_converter_rule("editorhtml", feature)
             if rule is not None:
                 # rule should be a list of WhitelistRule() instances - append this to
                 # the main converter_rules list
@@ -140,9 +147,7 @@ class EditorHTMLConverter:
             elif isinstance(rule, LinkTypeRule):
                 link_rules[rule.link_type] = rule.handler.expand_db_attributes
 
-        return MultiRuleRewriter([
-            LinkRewriter(link_rules), EmbedRewriter(embed_rules)
-        ])
+        return MultiRuleRewriter([LinkRewriter(link_rules), EmbedRewriter(embed_rules)])
 
     def from_database_format(self, html):
         return self.html_rewriter(html)
@@ -155,6 +160,7 @@ class PageLinkHandler:
     representation will be:
     <a linktype="page" id="42">hello world</a>
     """
+
     @staticmethod
     def get_db_attributes(tag):
         """
@@ -162,12 +168,12 @@ class PageLinkHandler:
         data-linktype="page" attribute), return a dict of the attributes we should
         have on the resulting <a linktype="page"> element.
         """
-        return {'id': tag['data-id']}
+        return {"id": tag["data-id"]}
 
     @staticmethod
     def expand_db_attributes(attrs):
         try:
-            page = Page.objects.get(id=attrs['id'])
+            page = Page.objects.get(id=attrs["id"])
 
             attrs = 'data-linktype="page" data-id="%d" ' % page.id
             parent_page = page.get_parent()

+ 17 - 10
wagtail/admin/rich_text/converters/html_ruleset.py

@@ -1,16 +1,20 @@
 import re
-
 from collections.abc import Mapping
 
-
-ELEMENT_SELECTOR = re.compile(r'^([\w-]+)$')
-ELEMENT_WITH_ATTR_SELECTOR = re.compile(r'^([\w-]+)\[([\w-]+)\]$')
-ELEMENT_WITH_ATTR_EXACT_SINGLE_QUOTE_SELECTOR = re.compile(r"^([\w-]+)\[([\w-]+)='(.*)'\]$")
-ELEMENT_WITH_ATTR_EXACT_DOUBLE_QUOTE_SELECTOR = re.compile(r'^([\w-]+)\[([\w-]+)="(.*)"\]$')
-ELEMENT_WITH_ATTR_EXACT_UNQUOTED_SELECTOR = re.compile(r"^([\w-]+)\[([\w-]+)=([\w-]+)\]$")
+ELEMENT_SELECTOR = re.compile(r"^([\w-]+)$")
+ELEMENT_WITH_ATTR_SELECTOR = re.compile(r"^([\w-]+)\[([\w-]+)\]$")
+ELEMENT_WITH_ATTR_EXACT_SINGLE_QUOTE_SELECTOR = re.compile(
+    r"^([\w-]+)\[([\w-]+)='(.*)'\]$"
+)
+ELEMENT_WITH_ATTR_EXACT_DOUBLE_QUOTE_SELECTOR = re.compile(
+    r'^([\w-]+)\[([\w-]+)="(.*)"\]$'
+)
+ELEMENT_WITH_ATTR_EXACT_UNQUOTED_SELECTOR = re.compile(
+    r"^([\w-]+)\[([\w-]+)=([\w-]+)\]$"
+)
 
 
-class HTMLRuleset():
+class HTMLRuleset:
     """
     Maintains a set of rules for matching HTML elements.
     Each rule defines a mapping from a CSS-like selector to an arbitrary result object.
@@ -20,6 +24,7 @@ class HTMLRuleset():
     'a[href]' = matches any <a> element with an 'href' attribute
     'a[linktype="page"]' = matches any <a> element with a 'linktype' attribute equal to 'page'
     """
+
     def __init__(self, rules=None):
         # mapping of element name to a sorted list of (precedence, attr_check, result) tuples
         # where attr_check is a callable that takes an attr dict and returns True if they match
@@ -57,7 +62,9 @@ class HTMLRuleset():
         # attribute `attr` equal to `value`
         rules = self.element_rules.setdefault(name, [])
         # element-and-attr rules have priority 1 (higher)
-        rules.append((1, (lambda attrs: attr in attrs and attrs[attr] == value), result))
+        rules.append(
+            (1, (lambda attrs: attr in attrs and attrs[attr] == value), result)
+        )
         # sort list on priority
         rules.sort(key=lambda t: t[0])
 
@@ -77,7 +84,7 @@ class HTMLRuleset():
         for regex in (
             ELEMENT_WITH_ATTR_EXACT_SINGLE_QUOTE_SELECTOR,
             ELEMENT_WITH_ATTR_EXACT_DOUBLE_QUOTE_SELECTOR,
-            ELEMENT_WITH_ATTR_EXACT_UNQUOTED_SELECTOR
+            ELEMENT_WITH_ATTR_EXACT_UNQUOTED_SELECTOR,
         ):
             match = regex.match(selector)
             if match:

+ 104 - 66
wagtail/admin/rich_text/converters/html_to_contentstate.py

@@ -1,24 +1,27 @@
 import re
-
 from html.parser import HTMLParser
 
 from wagtail.admin.rich_text.converters.contentstate_models import (
-    Block, ContentState, Entity, EntityRange, InlineStyleRange)
+    Block,
+    ContentState,
+    Entity,
+    EntityRange,
+    InlineStyleRange,
+)
 from wagtail.admin.rich_text.converters.html_ruleset import HTMLRuleset
 from wagtail.core.models import Page
 from wagtail.core.rich_text import features as feature_registry
 
-
 # constants to keep track of what to do with leading whitespace on the next text node we encounter
 STRIP_WHITESPACE = 0
 KEEP_WHITESPACE = 1
 FORCE_WHITESPACE = 2
 
 # match one or more consecutive normal spaces, new-lines, tabs and form-feeds
-WHITESPACE_RE = re.compile(r'[ \t\n\f\r]+')
+WHITESPACE_RE = re.compile(r"[ \t\n\f\r]+")
 
 # the attribute name to persist the Draftail block key between FE and db
-BLOCK_KEY_NAME = 'data-block-key'
+BLOCK_KEY_NAME = "data-block-key"
 
 
 class HandlerState:
@@ -42,23 +45,25 @@ class HandlerState:
         self.pushed_states = []
 
     def push(self):
-        self.pushed_states.append({
-            'current_block': self.current_block,
-            'current_inline_styles': self.current_inline_styles,
-            'current_entity_ranges': self.current_entity_ranges,
-            'leading_whitespace': self.leading_whitespace,
-            'list_depth': self.list_depth,
-            'list_item_type': self.list_item_type,
-        })
+        self.pushed_states.append(
+            {
+                "current_block": self.current_block,
+                "current_inline_styles": self.current_inline_styles,
+                "current_entity_ranges": self.current_entity_ranges,
+                "leading_whitespace": self.leading_whitespace,
+                "list_depth": self.list_depth,
+                "list_item_type": self.list_item_type,
+            }
+        )
 
     def pop(self):
         last_state = self.pushed_states.pop()
-        self.current_block = last_state['current_block']
-        self.current_inline_styles = last_state['current_inline_styles']
-        self.current_entity_ranges = last_state['current_entity_ranges']
-        self.leading_whitespace = last_state['leading_whitespace']
-        self.list_depth = last_state['list_depth']
-        self.list_item_type = last_state['list_item_type']
+        self.current_block = last_state["current_block"]
+        self.current_inline_styles = last_state["current_inline_styles"]
+        self.current_entity_ranges = last_state["current_entity_ranges"]
+        self.leading_whitespace = last_state["leading_whitespace"]
+        self.list_depth = last_state["list_depth"]
+        self.list_item_type = last_state["list_item_type"]
 
 
 def add_paragraph_block(state, contentstate):
@@ -67,7 +72,7 @@ def add_paragraph_block(state, contentstate):
     useful for element handlers that aren't paragraph elements themselves, but need
     to insert paragraphs to ensure correctness
     """
-    block = Block('unstyled', depth=state.list_depth)
+    block = Block("unstyled", depth=state.list_depth)
     contentstate.blocks.append(block)
     state.current_block = block
     state.leading_whitespace = STRIP_WHITESPACE
@@ -75,7 +80,8 @@ def add_paragraph_block(state, contentstate):
 
 
 class ListElementHandler:
-    """ Handler for <ul> / <ol> tags """
+    """Handler for <ul> / <ol> tags"""
+
     def __init__(self, list_item_type):
         self.list_item_type = list_item_type
 
@@ -100,10 +106,14 @@ class BlockElementHandler:
         self.block_type = block_type
 
     def create_block(self, name, attrs, state, contentstate):
-        return Block(self.block_type, depth=state.list_depth, key=attrs.get(BLOCK_KEY_NAME))
+        return Block(
+            self.block_type, depth=state.list_depth, key=attrs.get(BLOCK_KEY_NAME)
+        )
 
     def handle_starttag(self, name, attrs, state, contentstate):
-        attr_dict = dict(attrs)  # convert attrs from list of (name, value) tuples to a dict
+        attr_dict = dict(
+            attrs
+        )  # convert attrs from list of (name, value) tuples to a dict
         block = self.create_block(name, attr_dict, state, contentstate)
         contentstate.blocks.append(block)
         state.current_block = block
@@ -111,20 +121,28 @@ class BlockElementHandler:
         state.has_preceding_nonatomic_block = True
 
     def handle_endtag(self, name, state, contentState):
-        assert not state.current_inline_styles, "End of block reached without closing inline style elements"
-        assert not state.current_entity_ranges, "End of block reached without closing entity elements"
+        assert (
+            not state.current_inline_styles
+        ), "End of block reached without closing inline style elements"
+        assert (
+            not state.current_entity_ranges
+        ), "End of block reached without closing entity elements"
         state.current_block = None
 
 
 class ListItemElementHandler(BlockElementHandler):
-    """ Handler for <li> tag """
+    """Handler for <li> tag"""
 
     def __init__(self):
         pass  # skip setting self.block_type
 
     def create_block(self, name, attrs, state, contentstate):
-        assert state.list_item_type is not None, "%s element found outside of an enclosing list element" % name
-        return Block(state.list_item_type, depth=state.list_depth, key=attrs.get(BLOCK_KEY_NAME))
+        assert state.list_item_type is not None, (
+            "%s element found outside of an enclosing list element" % name
+        )
+        return Block(
+            state.list_item_type, depth=state.list_depth, key=attrs.get(BLOCK_KEY_NAME)
+        )
 
 
 class InlineStyleElementHandler:
@@ -140,7 +158,7 @@ class InlineStyleElementHandler:
         if state.leading_whitespace == FORCE_WHITESPACE:
             # any pending whitespace should be output before handling this tag,
             # and subsequent whitespace should be collapsed into it (= stripped)
-            state.current_block.text += ' '
+            state.current_block.text += " "
             state.leading_whitespace = STRIP_WHITESPACE
 
         inline_style_range = InlineStyleRange(self.style)
@@ -151,7 +169,9 @@ class InlineStyleElementHandler:
     def handle_endtag(self, name, state, contentstate):
         inline_style_range = state.current_inline_styles.pop()
         assert inline_style_range.style == self.style
-        inline_style_range.length = len(state.current_block.text) - inline_style_range.offset
+        inline_style_range.length = (
+            len(state.current_block.text) - inline_style_range.offset
+        )
 
 
 class InlineEntityElementHandler:
@@ -159,6 +179,7 @@ class InlineEntityElementHandler:
     Abstract superclass for elements that will be represented as inline entities.
     Subclasses should define a `mutability` property
     """
+
     def __init__(self, entity_type):
         self.entity_type = entity_type
 
@@ -171,14 +192,16 @@ class InlineEntityElementHandler:
         if state.leading_whitespace == FORCE_WHITESPACE:
             # any pending whitespace should be output before handling this tag,
             # and subsequent whitespace should be collapsed into it (= stripped)
-            state.current_block.text += ' '
+            state.current_block.text += " "
             state.leading_whitespace = STRIP_WHITESPACE
 
         # convert attrs from a list of (name, value) tuples to a dict
         # for get_attribute_data to work with
         attrs = dict(attrs)
 
-        entity = Entity(self.entity_type, self.mutability, self.get_attribute_data(attrs))
+        entity = Entity(
+            self.entity_type, self.mutability, self.get_attribute_data(attrs)
+        )
         key = contentstate.add_entity(entity)
 
         entity_range = EntityRange(key)
@@ -199,32 +222,28 @@ class InlineEntityElementHandler:
 
 
 class LinkElementHandler(InlineEntityElementHandler):
-    mutability = 'MUTABLE'
+    mutability = "MUTABLE"
 
 
 class ExternalLinkElementHandler(LinkElementHandler):
     def get_attribute_data(self, attrs):
-        return {'url': attrs['href']}
+        return {"url": attrs["href"]}
 
 
 class PageLinkElementHandler(LinkElementHandler):
     def get_attribute_data(self, attrs):
         try:
-            page = Page.objects.get(id=attrs['id']).specific
+            page = Page.objects.get(id=attrs["id"]).specific
         except Page.DoesNotExist:
             # retain ID so that it's still identified as a page link (albeit a broken one)
-            return {
-                'id': int(attrs['id']),
-                'url': None,
-                'parentId': None
-            }
+            return {"id": int(attrs["id"]), "url": None, "parentId": None}
 
         parent_page = page.get_parent()
 
         return {
-            'id': page.id,
-            'url': page.url,
-            'parentId': parent_page.id if parent_page else None,
+            "id": page.id,
+            "url": page.url,
+            "parentId": parent_page.id if parent_page else None,
         }
 
 
@@ -232,6 +251,7 @@ class AtomicBlockEntityElementHandler:
     """
     Handler for elements like <img> that exist as a single immutable item at the block level
     """
+
     def handle_starttag(self, name, attrs, state, contentstate):
         if state.current_block:
             # Placing an atomic block inside another block (e.g. a paragraph) is invalid in
@@ -240,11 +260,15 @@ class AtomicBlockEntityElementHandler:
 
             # Construct a new block of the same type and depth as the currently open one; this will
             # become the new 'current block' after we've added the atomic block.
-            next_block = Block(state.current_block.type, depth=state.current_block.depth)
+            next_block = Block(
+                state.current_block.type, depth=state.current_block.depth
+            )
 
             for inline_style_range in state.current_inline_styles:
                 # set this inline style to end at the current text position
-                inline_style_range.length = len(state.current_block.text) - inline_style_range.offset
+                inline_style_range.length = (
+                    len(state.current_block.text) - inline_style_range.offset
+                )
                 # start a new one of the same type, which will begin at the next block
                 new_inline_style = InlineStyleRange(inline_style_range.style)
                 new_inline_style.offset = 0
@@ -252,7 +276,9 @@ class AtomicBlockEntityElementHandler:
 
             for entity_range in state.current_entity_ranges:
                 # set this inline entity to end at the current text position
-                entity_range.length = len(state.current_block.text) - entity_range.offset
+                entity_range.length = (
+                    len(state.current_block.text) - entity_range.offset
+                )
                 # start a new entity range, pointing to the same entity, to begin at the next block
                 new_entity_range = EntityRange(entity_range.key)
                 new_entity_range.offset = 0
@@ -270,13 +296,15 @@ class AtomicBlockEntityElementHandler:
             # of this handler don't think we're inside it
             state.current_block = None
 
-        attr_dict = dict(attrs)  # convert attrs from list of (name, value) tuples to a dict
+        attr_dict = dict(
+            attrs
+        )  # convert attrs from list of (name, value) tuples to a dict
         entity = self.create_entity(name, attr_dict, state, contentstate)
         key = contentstate.add_entity(entity)
 
-        block = Block('atomic', depth=state.list_depth)
+        block = Block("atomic", depth=state.list_depth)
         contentstate.blocks.append(block)
-        block.text = ' '
+        block.text = " "
         entity_range = EntityRange(key)
         entity_range.offset = 0
         entity_range.length = 1
@@ -301,7 +329,7 @@ class AtomicBlockEntityElementHandler:
 
 class HorizontalRuleHandler(AtomicBlockEntityElementHandler):
     def create_entity(self, name, attrs, state, contentstate):
-        return Entity('HORIZONTAL_RULE', 'IMMUTABLE', {})
+        return Entity("HORIZONTAL_RULE", "IMMUTABLE", {})
 
 
 class LineBreakHandler:
@@ -310,7 +338,7 @@ class LineBreakHandler:
             # ignore line breaks that exist at the top level
             return
 
-        state.current_block.text += '\n'
+        state.current_block.text += "\n"
 
     def handle_endtag(self, name, state, contentstate):
         pass
@@ -318,15 +346,17 @@ class LineBreakHandler:
 
 class HtmlToContentStateHandler(HTMLParser):
     def __init__(self, features=()):
-        self.paragraph_handler = BlockElementHandler('unstyled')
-        self.element_handlers = HTMLRuleset({
-            'p': self.paragraph_handler,
-            'br': LineBreakHandler(),
-        })
+        self.paragraph_handler = BlockElementHandler("unstyled")
+        self.element_handlers = HTMLRuleset(
+            {
+                "p": self.paragraph_handler,
+                "br": LineBreakHandler(),
+            }
+        )
         for feature in features:
-            rule = feature_registry.get_converter_rule('contentstate', feature)
+            rule = feature_registry.get_converter_rule("contentstate", feature)
             if rule is not None:
-                self.element_handlers.add_rules(rule['from_database_format'])
+                self.element_handlers.add_rules(rule["from_database_format"])
 
         super().__init__(convert_charrefs=True)
 
@@ -340,7 +370,9 @@ class HtmlToContentStateHandler(HTMLParser):
         super().reset()
 
     def handle_starttag(self, name, attrs):
-        attr_dict = dict(attrs)  # convert attrs from list of (name, value) tuples to a dict
+        attr_dict = dict(
+            attrs
+        )  # convert attrs from list of (name, value) tuples to a dict
         element_handler = self.element_handlers.match(name, attr_dict)
 
         if element_handler is None and not self.open_elements:
@@ -356,7 +388,10 @@ class HtmlToContentStateHandler(HTMLParser):
         if not self.open_elements:
             return  # avoid a pop from an empty list if we have an extra end tag
         expected_name, element_handler = self.open_elements.pop()
-        assert name == expected_name, "Unmatched tags: expected %s, got %s" % (expected_name, name)
+        assert name == expected_name, "Unmatched tags: expected %s, got %s" % (
+            expected_name,
+            name,
+        )
         if element_handler:
             element_handler.handle_endtag(name, self.state, self.contentstate)
 
@@ -364,17 +399,17 @@ class HtmlToContentStateHandler(HTMLParser):
         # normalise whitespace sequences to a single space unless whitespace is contained in <pre> tag,
         # in which case, leave it alone
         # This is in line with https://www.w3.org/TR/html4/struct/text.html#h-9.1
-        content = re.sub(WHITESPACE_RE, ' ', content)
+        content = re.sub(WHITESPACE_RE, " ", content)
 
         if self.state.current_block is None:
-            if content == ' ':
+            if content == " ":
                 # ignore top-level whitespace
                 return
             else:
                 # create a new paragraph block for this content
                 add_paragraph_block(self.state, self.contentstate)
 
-        if content == ' ':
+        if content == " ":
             # if leading_whitespace = strip, this whitespace node is not significant
             #   and should be skipped.
             # For other cases, _don't_ output the whitespace yet, but set leading_whitespace = force
@@ -386,9 +421,12 @@ class HtmlToContentStateHandler(HTMLParser):
             # strip or add leading whitespace according to the leading_whitespace flag
             if self.state.leading_whitespace == STRIP_WHITESPACE:
                 content = content.lstrip()
-            elif self.state.leading_whitespace == FORCE_WHITESPACE and not content.startswith(' '):
-                content = ' ' + content
-            if content.endswith(' '):
+            elif (
+                self.state.leading_whitespace == FORCE_WHITESPACE
+                and not content.startswith(" ")
+            ):
+                content = " " + content
+            if content.endswith(" "):
                 # don't output trailing whitespace yet, because we want to discard it if the end
                 # of the block follows. Instead, we'll set leading_whitespace = force so that
                 # any following text or inline element will be prefixed by a space

+ 17 - 16
wagtail/admin/rich_text/editors/draftail/__init__.py

@@ -13,7 +13,7 @@ from wagtail.core.widget_adapters import WidgetAdapter
 
 
 class DraftailRichTextArea(widgets.HiddenInput):
-    template_name = 'wagtailadmin/widgets/draftail_rich_text_area.html'
+    template_name = "wagtailadmin/widgets/draftail_rich_text_area.html"
     is_hidden = False
 
     # this class's constructor accepts a 'features' kwarg
@@ -28,20 +28,20 @@ class DraftailRichTextArea(widgets.HiddenInput):
     def __init__(self, *args, **kwargs):
         # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting,
         # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg)
-        kwargs.pop('options', None)
+        kwargs.pop("options", None)
         self.options = {}
         self.plugins = []
 
-        self.features = kwargs.pop('features', None)
+        self.features = kwargs.pop("features", None)
         if self.features is None:
             self.features = feature_registry.get_default_features()
 
         for feature in self.features:
-            plugin = feature_registry.get_editor_plugin('draftail', feature)
+            plugin = feature_registry.get_editor_plugin("draftail", feature)
             if plugin is None:
                 warnings.warn(
                     f"Draftail received an unknown feature '{feature}'.",
-                    category=RuntimeWarning
+                    category=RuntimeWarning,
                 )
             else:
                 plugin.construct_options(self.options)
@@ -49,11 +49,11 @@ class DraftailRichTextArea(widgets.HiddenInput):
 
         self.converter = ContentstateConverter(self.features)
 
-        default_attrs = {'data-draftail-input': True}
-        attrs = kwargs.get('attrs')
+        default_attrs = {"data-draftail-input": True}
+        attrs = kwargs.get("attrs")
         if attrs:
             default_attrs.update(attrs)
-        kwargs['attrs'] = default_attrs
+        kwargs["attrs"] = default_attrs
 
         super().__init__(*args, **kwargs)
 
@@ -63,13 +63,13 @@ class DraftailRichTextArea(widgets.HiddenInput):
         value = super().format_value(value)
 
         if value is None:
-            value = ''
+            value = ""
 
         return self.converter.from_database_format(value)
 
     def get_context(self, name, value, attrs):
         context = super().get_context(name, value, attrs)
-        context['widget']['options_json'] = json.dumps(self.options)
+        context["widget"]["options_json"] = json.dumps(self.options)
         return context
 
     def value_from_datadict(self, data, files, name):
@@ -80,11 +80,12 @@ class DraftailRichTextArea(widgets.HiddenInput):
 
     @cached_property
     def media(self):
-        media = Media(js=[
-            versioned_static('wagtailadmin/js/draftail.js'),
-        ], css={
-            'all': [versioned_static('wagtailadmin/css/panels/draftail.css')]
-        })
+        media = Media(
+            js=[
+                versioned_static("wagtailadmin/js/draftail.js"),
+            ],
+            css={"all": [versioned_static("wagtailadmin/css/panels/draftail.css")]},
+        )
 
         for plugin in self.plugins:
             media += plugin.media
@@ -93,7 +94,7 @@ class DraftailRichTextArea(widgets.HiddenInput):
 
 
 class DraftailRichTextAreaAdapter(WidgetAdapter):
-    js_constructor = 'wagtail.widgets.DraftailRichTextArea'
+    js_constructor = "wagtail.widgets.DraftailRichTextArea"
 
     def js_args(self, widget):
         return [

+ 8 - 4
wagtail/admin/rich_text/editors/draftail/features.py

@@ -2,7 +2,6 @@ from django.forms import Media
 
 from wagtail.admin.staticfiles import versioned_static
 
-
 # Feature objects: these are mapped to feature identifiers within the rich text
 # feature registry (wagtail.core.rich_text.features). Each one implements
 # a `construct_options` method which modifies an options dict as appropriate to
@@ -33,6 +32,7 @@ class BooleanFeature(Feature):
     A feature which is enabled by a boolean flag at the top level of
     the options dict
     """
+
     def __init__(self, option_name, **kwargs):
         super().__init__(**kwargs)
         self.option_name = option_name
@@ -46,6 +46,7 @@ class ListFeature(Feature):
     Abstract class for features that are defined in a list within the options dict.
     Subclasses must define option_name
     """
+
     def __init__(self, data, **kwargs):
         super().__init__(**kwargs)
         self.data = data
@@ -59,14 +60,17 @@ class ListFeature(Feature):
 
 class EntityFeature(ListFeature):
     """A feature which is listed in the entityTypes list of the options"""
-    option_name = 'entityTypes'
+
+    option_name = "entityTypes"
 
 
 class BlockFeature(ListFeature):
     """A feature which is listed in the blockTypes list of the options"""
-    option_name = 'blockTypes'
+
+    option_name = "blockTypes"
 
 
 class InlineStyleFeature(ListFeature):
     """A feature which is listed in the inlineStyles list of the options"""
-    option_name = 'inlineStyles'
+
+    option_name = "inlineStyles"

+ 66 - 48
wagtail/admin/rich_text/editors/hallo.py

@@ -1,5 +1,4 @@
 import json
-
 from collections import OrderedDict
 
 from django.forms import Media, widgets
@@ -15,11 +14,11 @@ from wagtail.core.widget_adapters import WidgetAdapter
 
 class HalloPlugin:
     def __init__(self, **kwargs):
-        self.name = kwargs.get('name', None)
-        self.options = kwargs.get('options', {})
-        self.js = kwargs.get('js', [])
-        self.css = kwargs.get('css', {})
-        self.order = kwargs.get('order', 100)
+        self.name = kwargs.get("name", None)
+        self.options = kwargs.get("options", {})
+        self.js = kwargs.get("js", [])
+        self.css = kwargs.get("css", {})
+        self.order = kwargs.get("order", 100)
 
     def construct_plugins_list(self, plugins):
         if self.name is not None:
@@ -37,64 +36,77 @@ class HalloPlugin:
 
 class HalloFormatPlugin(HalloPlugin):
     def __init__(self, **kwargs):
-        kwargs.setdefault('name', 'halloformat')
-        kwargs.setdefault('order', 10)
-        self.format_name = kwargs['format_name']
+        kwargs.setdefault("name", "halloformat")
+        kwargs.setdefault("order", 10)
+        self.format_name = kwargs["format_name"]
         super().__init__(**kwargs)
 
     def construct_plugins_list(self, plugins):
-        plugins.setdefault(self.name, {'formattings': {
-            'bold': False, 'italic': False, 'strikeThrough': False, 'underline': False
-        }})
-        plugins[self.name]['formattings'][self.format_name] = True
+        plugins.setdefault(
+            self.name,
+            {
+                "formattings": {
+                    "bold": False,
+                    "italic": False,
+                    "strikeThrough": False,
+                    "underline": False,
+                }
+            },
+        )
+        plugins[self.name]["formattings"][self.format_name] = True
 
 
 class HalloHeadingPlugin(HalloPlugin):
     default_order = 20
 
     def __init__(self, **kwargs):
-        kwargs.setdefault('name', 'halloheadings')
-        kwargs.setdefault('order', self.default_order)
-        self.element = kwargs.pop('element')
+        kwargs.setdefault("name", "halloheadings")
+        kwargs.setdefault("order", self.default_order)
+        self.element = kwargs.pop("element")
         super().__init__(**kwargs)
 
     def construct_plugins_list(self, plugins):
-        plugins.setdefault(self.name, {'formatBlocks': []})
-        plugins[self.name]['formatBlocks'].append(self.element)
+        plugins.setdefault(self.name, {"formatBlocks": []})
+        plugins[self.name]["formatBlocks"].append(self.element)
 
 
 class HalloListPlugin(HalloPlugin):
     def __init__(self, **kwargs):
-        kwargs.setdefault('name', 'hallolists')
-        kwargs.setdefault('order', 40)
-        self.list_type = kwargs['list_type']
+        kwargs.setdefault("name", "hallolists")
+        kwargs.setdefault("order", 40)
+        self.list_type = kwargs["list_type"]
         super().__init__(**kwargs)
 
     def construct_plugins_list(self, plugins):
-        plugins.setdefault(self.name, {'lists': {
-            'ordered': False, 'unordered': False
-        }})
-        plugins[self.name]['lists'][self.list_type] = True
+        plugins.setdefault(self.name, {"lists": {"ordered": False, "unordered": False}})
+        plugins[self.name]["lists"][self.list_type] = True
 
 
 class HalloRequireParagraphsPlugin(HalloPlugin):
     @property
     def media(self):
-        return Media(js=[
-            versioned_static('wagtailadmin/js/hallo-plugins/hallo-requireparagraphs.js'),
-        ]) + super().media
+        return (
+            Media(
+                js=[
+                    versioned_static(
+                        "wagtailadmin/js/hallo-plugins/hallo-requireparagraphs.js"
+                    ),
+                ]
+            )
+            + super().media
+        )
 
 
 # Plugins which are always imported, and cannot be enabled/disabled via 'features'
 CORE_HALLO_PLUGINS = [
-    HalloPlugin(name='halloreundo', order=50),
-    HalloRequireParagraphsPlugin(name='hallorequireparagraphs'),
-    HalloHeadingPlugin(element='p')
+    HalloPlugin(name="halloreundo", order=50),
+    HalloRequireParagraphsPlugin(name="hallorequireparagraphs"),
+    HalloHeadingPlugin(element="p"),
 ]
 
 
 class HalloRichTextArea(widgets.Textarea):
-    template_name = 'wagtailadmin/widgets/hallo_rich_text_area.html'
+    template_name = "wagtailadmin/widgets/hallo_rich_text_area.html"
 
     # this class's constructor accepts a 'features' kwarg
     accepts_features = True
@@ -103,9 +115,9 @@ class HalloRichTextArea(widgets.Textarea):
         return RichTextFieldPanel
 
     def __init__(self, *args, **kwargs):
-        self.options = kwargs.pop('options', None)
+        self.options = kwargs.pop("options", None)
 
-        self.features = kwargs.pop('features', None)
+        self.features = kwargs.pop("features", None)
         if self.features is None:
             self.features = features.get_default_features()
 
@@ -113,10 +125,15 @@ class HalloRichTextArea(widgets.Textarea):
 
         # construct a list of plugin objects, by querying the feature registry
         # and keeping the non-null responses from get_editor_plugin
-        self.plugins = CORE_HALLO_PLUGINS + list(filter(None, [
-            features.get_editor_plugin('hallo', feature_name)
-            for feature_name in self.features
-        ]))
+        self.plugins = CORE_HALLO_PLUGINS + list(
+            filter(
+                None,
+                [
+                    features.get_editor_plugin("hallo", feature_name)
+                    for feature_name in self.features
+                ],
+            )
+        )
         self.plugins.sort(key=lambda plugin: plugin.order)
 
         super().__init__(*args, **kwargs)
@@ -134,14 +151,14 @@ class HalloRichTextArea(widgets.Textarea):
     def get_context(self, name, value, attrs):
         context = super().get_context(name, value, attrs)
 
-        if self.options is not None and 'plugins' in self.options:
+        if self.options is not None and "plugins" in self.options:
             # explicit 'plugins' config passed in options, so use that
-            plugin_data = self.options['plugins']
+            plugin_data = self.options["plugins"]
         else:
             plugin_data = OrderedDict()
             for plugin in self.plugins:
                 plugin.construct_plugins_list(plugin_data)
-        context['widget']['plugins_json'] = json.dumps(plugin_data)
+        context["widget"]["plugins_json"] = json.dumps(plugin_data)
 
         return context
 
@@ -153,12 +170,13 @@ class HalloRichTextArea(widgets.Textarea):
 
     @cached_property
     def media(self):
-        media = Media(js=[
-            versioned_static('wagtailadmin/js/vendor/hallo.js'),
-            versioned_static('wagtailadmin/js/hallo-bootstrap.js'),
-        ], css={
-            'all': [versioned_static('wagtailadmin/css/panels/hallo.css')]
-        })
+        media = Media(
+            js=[
+                versioned_static("wagtailadmin/js/vendor/hallo.js"),
+                versioned_static("wagtailadmin/js/hallo-bootstrap.js"),
+            ],
+            css={"all": [versioned_static("wagtailadmin/css/panels/hallo.css")]},
+        )
 
         for plugin in self.plugins:
             media += plugin.media
@@ -167,7 +185,7 @@ class HalloRichTextArea(widgets.Textarea):
 
 
 class HalloRichTextAreaAdapter(WidgetAdapter):
-    js_constructor = 'wagtail.widgets.HalloRichTextArea'
+    js_constructor = "wagtail.widgets.HalloRichTextArea"
 
 
 register(HalloRichTextAreaAdapter(), HalloRichTextArea)

+ 31 - 18
wagtail/admin/search.py

@@ -11,14 +11,16 @@ from wagtail.core import hooks
 
 @total_ordering
 class SearchArea(metaclass=MediaDefiningClass):
-    template = 'wagtailadmin/shared/search_area.html'
+    template = "wagtailadmin/shared/search_area.html"
 
-    def __init__(self, label, url, name=None, classnames='', icon_name='', attrs=None, order=1000):
+    def __init__(
+        self, label, url, name=None, classnames="", icon_name="", attrs=None, order=1000
+    ):
         self.label = label
         self.url = url
         self.classnames = classnames
         self.icon_name = icon_name
-        self.name = (name or slugify(str(label)))
+        self.name = name or slugify(str(label))
         self.order = order
 
         if attrs:
@@ -46,16 +48,20 @@ class SearchArea(metaclass=MediaDefiningClass):
             return self.name == current
 
     def render_html(self, request, query, current=None):
-        return render_to_string(self.template, {
-            'name': self.name,
-            'url': self.url,
-            'classnames': self.classnames,
-            'icon_name': self.icon_name,
-            'attr_string': self.attr_string,
-            'label': self.label,
-            'active': self.is_active(request, current),
-            'query_string': query
-        }, request=request)
+        return render_to_string(
+            self.template,
+            {
+                "name": self.name,
+                "url": self.url,
+                "classnames": self.classnames,
+                "icon_name": self.icon_name,
+                "attr_string": self.attr_string,
+                "label": self.label,
+                "active": self.is_active(request, current),
+                "query_string": query,
+            },
+            request=request,
+        )
 
 
 class Search:
@@ -71,7 +77,11 @@ class Search:
         return [item for item in self.registered_search_areas if item.is_shown(request)]
 
     def active_search(self, request, current=None):
-        return [item for item in self.search_items_for_request(request) if item.is_active(request, current)]
+        return [
+            item
+            for item in self.search_items_for_request(request)
+            if item.is_active(request, current)
+        ]
 
     @property
     def media(self):
@@ -85,9 +95,9 @@ class Search:
 
         # Get query parameter
         form = SearchForm(request.GET)
-        query = ''
+        query = ""
         if form.is_valid():
-            query = form.cleaned_data['q']
+            query = form.cleaned_data["q"]
 
         # provide a hook for modifying the search area, if construct_hook_name has been set
         if self.construct_hook_name:
@@ -98,7 +108,10 @@ class Search:
         for item in search_areas:
             rendered_search_areas.append(item.render_html(request, query, current))
 
-        return mark_safe(''.join(rendered_search_areas))
+        return mark_safe("".join(rendered_search_areas))
 
 
-admin_search_areas = Search(register_hook_name='register_admin_search_area', construct_hook_name='construct_search')
+admin_search_areas = Search(
+    register_hook_name="register_admin_search_area",
+    construct_hook_name="construct_search",
+)

+ 30 - 8
wagtail/admin/signal_handlers.py

@@ -1,10 +1,16 @@
 from wagtail.admin.mail import (
-    GroupApprovalTaskStateSubmissionEmailNotifier, WorkflowStateApprovalEmailNotifier,
-    WorkflowStateRejectionEmailNotifier, WorkflowStateSubmissionEmailNotifier)
+    GroupApprovalTaskStateSubmissionEmailNotifier,
+    WorkflowStateApprovalEmailNotifier,
+    WorkflowStateRejectionEmailNotifier,
+    WorkflowStateSubmissionEmailNotifier,
+)
 from wagtail.core.models import TaskState, WorkflowState
 from wagtail.core.signals import (
-    task_submitted, workflow_approved, workflow_rejected, workflow_submitted)
-
+    task_submitted,
+    workflow_approved,
+    workflow_rejected,
+    workflow_submitted,
+)
 
 task_submission_email_notifier = GroupApprovalTaskStateSubmissionEmailNotifier()
 workflow_submission_email_notifier = WorkflowStateSubmissionEmailNotifier()
@@ -13,8 +19,24 @@ workflow_rejection_email_notifier = WorkflowStateRejectionEmailNotifier()
 
 
 def register_signal_handlers():
-    task_submitted.connect(task_submission_email_notifier, sender=TaskState, dispatch_uid='group_approval_task_submitted_email_notification')
+    task_submitted.connect(
+        task_submission_email_notifier,
+        sender=TaskState,
+        dispatch_uid="group_approval_task_submitted_email_notification",
+    )
 
-    workflow_submitted.connect(workflow_submission_email_notifier, sender=WorkflowState, dispatch_uid='workflow_state_submitted_email_notification')
-    workflow_rejected.connect(workflow_rejection_email_notifier, sender=WorkflowState, dispatch_uid='workflow_state_rejected_email_notification')
-    workflow_approved.connect(workflow_approval_email_notifier, sender=WorkflowState, dispatch_uid='workflow_state_approved_email_notification')
+    workflow_submitted.connect(
+        workflow_submission_email_notifier,
+        sender=WorkflowState,
+        dispatch_uid="workflow_state_submitted_email_notification",
+    )
+    workflow_rejected.connect(
+        workflow_rejection_email_notifier,
+        sender=WorkflowState,
+        dispatch_uid="workflow_state_rejected_email_notification",
+    )
+    workflow_approved.connect(
+        workflow_approval_email_notifier,
+        sender=WorkflowState,
+        dispatch_uid="workflow_state_approved_email_notification",
+    )

+ 0 - 1
wagtail/admin/signals.py

@@ -1,5 +1,4 @@
 from django.dispatch import Signal
 
-
 # provides args: page, parent
 init_new_page = Signal()

+ 10 - 10
wagtail/admin/site_summary.py

@@ -19,12 +19,12 @@ class SummaryItem(Component):
 
 class PagesSummaryItem(SummaryItem):
     order = 100
-    template_name = 'wagtailadmin/home/site_summary_pages.html'
+    template_name = "wagtailadmin/home/site_summary_pages.html"
 
     def get_context_data(self, parent_context):
         site_details = get_site_for_user(self.request.user)
-        root_page = site_details['root_page']
-        site_name = site_details['site_name']
+        root_page = site_details["root_page"]
+        site_name = site_details["site_name"]
 
         if root_page:
             page_count = Page.objects.descendant_of(root_page, inclusive=True).count()
@@ -45,9 +45,9 @@ class PagesSummaryItem(SummaryItem):
             page_count = 0
 
         return {
-            'root_page': root_page,
-            'total_pages': page_count,
-            'site_name': site_name,
+            "root_page": root_page,
+            "total_pages": page_count,
+            "site_name": site_name,
         }
 
     def is_shown(self):
@@ -55,21 +55,21 @@ class PagesSummaryItem(SummaryItem):
 
 
 class SiteSummaryPanel(Component):
-    name = 'site_summary'
-    template_name = 'wagtailadmin/home/site_summary.html'
+    name = "site_summary"
+    template_name = "wagtailadmin/home/site_summary.html"
     order = 100
 
     def __init__(self, request):
         self.request = request
         summary_items = []
-        for fn in hooks.get_hooks('construct_homepage_summary_items'):
+        for fn in hooks.get_hooks("construct_homepage_summary_items"):
             fn(request, summary_items)
         self.summary_items = [s for s in summary_items if s.is_shown()]
         self.summary_items.sort(key=lambda p: p.order)
 
     def get_context_data(self, parent_context):
         context = super().get_context_data(parent_context)
-        context['summary_items'] = self.summary_items
+        context["summary_items"] = self.summary_items
         return context
 
     @property

+ 4 - 5
wagtail/admin/staticfiles.py

@@ -7,7 +7,6 @@ from django.templatetags.static import static
 
 from wagtail import __version__
 
-
 # Check whether we should add cache-busting '?v=...' parameters to static file URLs
 try:
     # If a preference has been explicitly stated in the WAGTAILADMIN_STATIC_FILE_VERSION_STRINGS
@@ -30,7 +29,7 @@ except AttributeError:
 
 if use_version_strings:
     VERSION_HASH = hashlib.sha1(
-        (__version__ + settings.SECRET_KEY).encode('utf-8')
+        (__version__ + settings.SECRET_KEY).encode("utf-8")
     ).hexdigest()[:8]
 else:
     VERSION_HASH = None
@@ -42,14 +41,14 @@ def versioned_static(path):
     that updates on each Wagtail version
     """
     # An absolute path is returned unchanged (either a full URL, or processed already)
-    if path.startswith(('http://', 'https://', '/')):
+    if path.startswith(("http://", "https://", "/")):
         return path
 
     base_url = static(path)
 
     # if URL already contains a querystring, don't add our own, to avoid interfering
     # with existing mechanisms
-    if VERSION_HASH is None or '?' in base_url:
+    if VERSION_HASH is None or "?" in base_url:
         return base_url
     else:
-        return base_url + '?v=' + VERSION_HASH
+        return base_url + "?v=" + VERSION_HASH

+ 261 - 187
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -1,5 +1,4 @@
 import json
-
 from datetime import datetime
 from urllib.parse import urljoin
 
@@ -35,83 +34,105 @@ from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
 from wagtail.admin.widgets import ButtonWithDropdown, PageListingButton
 from wagtail.core import hooks
 from wagtail.core.models import (
-    Collection, CollectionViewRestriction, Locale, Page, PageViewRestriction,
-    UserPagePermissionsProxy)
+    Collection,
+    CollectionViewRestriction,
+    Locale,
+    Page,
+    PageViewRestriction,
+    UserPagePermissionsProxy,
+)
 from wagtail.core.telepath import JSContext
 from wagtail.core.utils import camelcase_to_underscore
 from wagtail.core.utils import cautious_slugify as _cautious_slugify
-from wagtail.core.utils import escape_script, get_content_type_label, get_locales_display_names
+from wagtail.core.utils import (
+    escape_script,
+    get_content_type_label,
+    get_locales_display_names,
+)
 from wagtail.users.utils import get_gravatar_url
 
-
 register = template.Library()
 
-register.filter('intcomma', intcomma)
+register.filter("intcomma", intcomma)
 
 
 @register.simple_tag(takes_context=True)
 def menu_search(context):
-    request = context['request']
+    request = context["request"]
 
     search_areas = admin_search_areas.search_items_for_request(request)
     if not search_areas:
-        return ''
+        return ""
     search_area = search_areas[0]
 
-    return render_to_string('wagtailadmin/shared/menu_search.html', {
-        'search_url': search_area.url,
-    })
+    return render_to_string(
+        "wagtailadmin/shared/menu_search.html",
+        {
+            "search_url": search_area.url,
+        },
+    )
 
 
-@register.inclusion_tag('wagtailadmin/shared/main_nav.html', takes_context=True)
+@register.inclusion_tag("wagtailadmin/shared/main_nav.html", takes_context=True)
 def main_nav(context):
-    request = context['request']
+    request = context["request"]
 
     return {
-        'menu_html': admin_menu.render_html(request),
-        'request': request,
+        "menu_html": admin_menu.render_html(request),
+        "request": request,
     }
 
 
-@register.inclusion_tag('wagtailadmin/shared/breadcrumb.html', takes_context=True)
-def explorer_breadcrumb(context, page, page_perms=None, include_self=True, trailing_arrow=False, show_header_buttons=False):
-    user = context['request'].user
+@register.inclusion_tag("wagtailadmin/shared/breadcrumb.html", takes_context=True)
+def explorer_breadcrumb(
+    context,
+    page,
+    page_perms=None,
+    include_self=True,
+    trailing_arrow=False,
+    show_header_buttons=False,
+):
+    user = context["request"].user
 
     # find the closest common ancestor of the pages that this user has direct explore permission
     # (i.e. add/edit/publish/lock) over; this will be the root of the breadcrumb
     cca = get_explorable_root_page(user)
     if not cca:
-        return {'pages': Page.objects.none()}
+        return {"pages": Page.objects.none()}
 
     return {
-        'pages': page.get_ancestors(inclusive=include_self).descendant_of(cca, inclusive=True).specific(),
-        'current_page': page,
-        'page_perms': page_perms,
-        'trailing_arrow': trailing_arrow,
-        'show_header_buttons': show_header_buttons,
+        "pages": page.get_ancestors(inclusive=include_self)
+        .descendant_of(cca, inclusive=True)
+        .specific(),
+        "current_page": page,
+        "page_perms": page_perms,
+        "trailing_arrow": trailing_arrow,
+        "show_header_buttons": show_header_buttons,
     }
 
 
-@register.inclusion_tag('wagtailadmin/shared/move_breadcrumb.html', takes_context=True)
+@register.inclusion_tag("wagtailadmin/shared/move_breadcrumb.html", takes_context=True)
 def move_breadcrumb(context, page_to_move, viewed_page):
-    user = context['request'].user
+    user = context["request"].user
     cca = get_explorable_root_page(user)
     if not cca:
-        return {'pages': Page.objects.none()}
+        return {"pages": Page.objects.none()}
 
     return {
-        'pages': viewed_page.get_ancestors(inclusive=True).descendant_of(cca, inclusive=True).specific(),
-        'page_to_move_id': page_to_move.id,
+        "pages": viewed_page.get_ancestors(inclusive=True)
+        .descendant_of(cca, inclusive=True)
+        .specific(),
+        "page_to_move_id": page_to_move.id,
     }
 
 
-@register.inclusion_tag('wagtailadmin/shared/search_other.html', takes_context=True)
+@register.inclusion_tag("wagtailadmin/shared/search_other.html", takes_context=True)
 def search_other(context, current=None):
-    request = context['request']
+    request = context["request"]
 
     return {
-        'options_html': admin_search_areas.render_html(request, current),
-        'request': request,
+        "options_html": admin_search_areas.render_html(request, current),
+        "request": request,
     }
 
 
@@ -120,33 +141,26 @@ def main_nav_js():
     if slim_sidebar_enabled():
         return Media(
             js=[
-                versioned_static('wagtailadmin/js/telepath/telepath.js'),
-                versioned_static('wagtailadmin/js/sidebar.js'),
+                versioned_static("wagtailadmin/js/telepath/telepath.js"),
+                versioned_static("wagtailadmin/js/sidebar.js"),
             ]
         )
 
     else:
-        return Media(
-            js=[
-                versioned_static('wagtailadmin/js/sidebar-legacy.js')
-            ]
-        ) + admin_menu.media['js']
+        return (
+            Media(js=[versioned_static("wagtailadmin/js/sidebar-legacy.js")])
+            + admin_menu.media["js"]
+        )
 
 
 @register.simple_tag
 def main_nav_css():
     if slim_sidebar_enabled():
-        return Media(
-            css={
-                'all': [
-                    versioned_static('wagtailadmin/css/sidebar.css')
-                ]
-            }
-        )
+        return Media(css={"all": [versioned_static("wagtailadmin/css/sidebar.css")]})
 
     else:
         # Legacy sidebar CSS in core.css
-        return admin_menu.media['css']
+        return admin_menu.media["css"]
 
 
 @register.filter("ellipsistrim")
@@ -154,7 +168,7 @@ def ellipsistrim(value, max_length):
     if len(value) > max_length:
         truncd_val = value[:max_length]
         if not len(value) == (max_length + 1) and value[max_length + 1] != " ":
-            truncd_val = truncd_val[:truncd_val.rfind(" ")]
+            truncd_val = truncd_val[: truncd_val.rfind(" ")]
         return truncd_val + "…"
     return value
 
@@ -184,10 +198,12 @@ def widgettype(bound_field):
 def _get_user_page_permissions(context):
     # Create a UserPagePermissionsProxy object to represent the user's global permissions, and
     # cache it in the context for the duration of the page request, if one does not exist already
-    if 'user_page_permissions' not in context:
-        context['user_page_permissions'] = UserPagePermissionsProxy(context['request'].user)
+    if "user_page_permissions" not in context:
+        context["user_page_permissions"] = UserPagePermissionsProxy(
+            context["request"].user
+        )
 
-    return context['user_page_permissions']
+    return context["user_page_permissions"]
 
 
 @register.simple_tag(takes_context=True)
@@ -209,12 +225,14 @@ def test_collection_is_public(context, collection):
     Caches the list of collection view restrictions in the context, to avoid repeated
     DB queries on repeated calls.
     """
-    if 'all_collection_view_restrictions' not in context:
-        context['all_collection_view_restrictions'] = CollectionViewRestriction.objects.select_related('collection').values_list(
-            'collection__name', flat=True
+    if "all_collection_view_restrictions" not in context:
+        context[
+            "all_collection_view_restrictions"
+        ] = CollectionViewRestriction.objects.select_related("collection").values_list(
+            "collection__name", flat=True
         )
 
-    is_private = collection.name in context['all_collection_view_restrictions']
+    is_private = collection.name in context["all_collection_view_restrictions"]
 
     return not is_private
 
@@ -229,14 +247,20 @@ def test_page_is_public(context, page):
     DB queries on repeated calls.
     """
     if not hasattr(context["request"], "all_page_view_restriction_paths"):
-        context['request'].all_page_view_restriction_paths = PageViewRestriction.objects.select_related('page').values_list(
-            'page__path', flat=True
+        context[
+            "request"
+        ].all_page_view_restriction_paths = PageViewRestriction.objects.select_related(
+            "page"
+        ).values_list(
+            "page__path", flat=True
         )
 
-    is_private = any([
-        page.path.startswith(restricted_path)
-        for restricted_path in context["request"].all_page_view_restriction_paths
-    ])
+    is_private = any(
+        [
+            page.path.startswith(restricted_path)
+            for restricted_path in context["request"].all_page_view_restriction_paths
+        ]
+    )
 
     return not is_private
 
@@ -250,31 +274,31 @@ def hook_output(hook_name):
     Note that the output is not escaped - it is the hook function's responsibility to escape unsafe content.
     """
     snippets = [fn() for fn in hooks.get_hooks(hook_name)]
-    return mark_safe(''.join(snippets))
+    return mark_safe("".join(snippets))
 
 
 @register.simple_tag
 def usage_count_enabled():
-    return getattr(settings, 'WAGTAIL_USAGE_COUNT_ENABLED', False)
+    return getattr(settings, "WAGTAIL_USAGE_COUNT_ENABLED", False)
 
 
 @register.simple_tag
 def base_url_setting():
-    return getattr(settings, 'BASE_URL', None)
+    return getattr(settings, "BASE_URL", None)
 
 
 @register.simple_tag
 def allow_unicode_slugs():
-    return getattr(settings, 'WAGTAIL_ALLOW_UNICODE_SLUGS', True)
+    return getattr(settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True)
 
 
 @register.simple_tag
 def auto_update_preview():
-    return getattr(settings, 'WAGTAIL_AUTO_UPDATE_PREVIEW', False)
+    return getattr(settings, "WAGTAIL_AUTO_UPDATE_PREVIEW", False)
 
 
 class EscapeScriptNode(template.Node):
-    TAG_NAME = 'escapescript'
+    TAG_NAME = "escapescript"
 
     def __init__(self, nodelist):
         super().__init__()
@@ -286,7 +310,7 @@ class EscapeScriptNode(template.Node):
 
     @classmethod
     def handle(cls, parser, token):
-        nodelist = parser.parse(('end' + EscapeScriptNode.TAG_NAME,))
+        nodelist = parser.parse(("end" + EscapeScriptNode.TAG_NAME,))
         parser.delete_first_token()
         return cls(nodelist)
 
@@ -304,12 +328,12 @@ def render_with_errors(bound_field):
     a render_with_errors method, call that; otherwise, call the regular widget rendering mechanism.
     """
     widget = bound_field.field.widget
-    if bound_field.errors and hasattr(widget, 'render_with_errors'):
+    if bound_field.errors and hasattr(widget, "render_with_errors"):
         return widget.render_with_errors(
             bound_field.html_name,
             bound_field.value(),
-            attrs={'id': bound_field.auto_id},
-            errors=bound_field.errors
+            attrs={"id": bound_field.auto_id},
+            errors=bound_field.errors,
         )
     else:
         return bound_field.as_widget()
@@ -321,7 +345,9 @@ def has_unrendered_errors(bound_field):
     Return true if this field has errors that were not accounted for by render_with_errors, because
     the widget does not support the render_with_errors method
     """
-    return bound_field.errors and not hasattr(bound_field.field.widget, 'render_with_errors')
+    return bound_field.errors and not hasattr(
+        bound_field.field.widget, "render_with_errors"
+    )
 
 
 @register.filter(is_safe=True)
@@ -342,7 +368,7 @@ def querystring(context, **kwargs):
 
         <a href="/page/?foo=bar&key=value">
     """
-    request = context['request']
+    request = context["request"]
     querydict = request.GET.copy()
     # Can't do querydict.update(kwargs), because QueryDict.update() appends to
     # the list of values, instead of replacing the values.
@@ -354,7 +380,7 @@ def querystring(context, **kwargs):
             # Set the key otherwise
             querydict[key] = str(value)
 
-    return '?' + querydict.urlencode()
+    return "?" + querydict.urlencode()
 
 
 @register.simple_tag(takes_context=True)
@@ -363,21 +389,43 @@ def page_table_header_label(context, label=None, parent_page_title=None, **kwarg
     Wraps table_header_label to add a title attribute based on the parent page title and the column label
     """
     if label:
-        translation_context = {'parent': parent_page_title, 'label': label}
-        ascending_title_text = _("Sort the order of child pages within '%(parent)s' by '%(label)s' in ascending order.") % translation_context
-        descending_title_text = _("Sort the order of child pages within '%(parent)s' by '%(label)s' in descending order.") % translation_context
+        translation_context = {"parent": parent_page_title, "label": label}
+        ascending_title_text = (
+            _(
+                "Sort the order of child pages within '%(parent)s' by '%(label)s' in ascending order."
+            )
+            % translation_context
+        )
+        descending_title_text = (
+            _(
+                "Sort the order of child pages within '%(parent)s' by '%(label)s' in descending order."
+            )
+            % translation_context
+        )
     else:
         ascending_title_text = None
         descending_title_text = None
 
-    return table_header_label(context, label=label, ascending_title_text=ascending_title_text, descending_title_text=descending_title_text, **kwargs)
+    return table_header_label(
+        context,
+        label=label,
+        ascending_title_text=ascending_title_text,
+        descending_title_text=descending_title_text,
+        **kwargs,
+    )
 
 
 @register.simple_tag(takes_context=True)
 def table_header_label(
-    context, label=None, sortable=True, ordering=None,
-    sort_context_var='ordering', sort_param='ordering', sort_field=None,
-    ascending_title_text=None, descending_title_text=None
+    context,
+    label=None,
+    sortable=True,
+    ordering=None,
+    sort_context_var="ordering",
+    sort_param="ordering",
+    sort_field=None,
+    ascending_title_text=None,
+    descending_title_text=None,
 ):
     """
     A label to go in a table header cell, optionally with a 'sort' link that alternates between
@@ -408,41 +456,42 @@ def table_header_label(
     if ordering == sort_field:
         # currently ordering forwards on this column; link should change to reverse ordering
         attrs = {
-            'href': querystring(context, **{sort_param: reverse_sort_field}),
-            'class': "icon icon-arrow-down-after teal",
+            "href": querystring(context, **{sort_param: reverse_sort_field}),
+            "class": "icon icon-arrow-down-after teal",
         }
         if descending_title_text is not None:
-            attrs['title'] = descending_title_text
+            attrs["title"] = descending_title_text
 
     elif ordering == reverse_sort_field:
         # currently ordering backwards on this column; link should change to forward ordering
         attrs = {
-            'href': querystring(context, **{sort_param: sort_field}),
-            'class': "icon icon-arrow-up-after teal",
+            "href": querystring(context, **{sort_param: sort_field}),
+            "class": "icon icon-arrow-up-after teal",
         }
         if ascending_title_text is not None:
-            attrs['title'] = ascending_title_text
+            attrs["title"] = ascending_title_text
 
     else:
         # not currently ordering on this column; link should change to forward ordering
         attrs = {
-            'href': querystring(context, **{sort_param: sort_field}),
-            'class': "icon icon-arrow-down-after",
+            "href": querystring(context, **{sort_param: sort_field}),
+            "class": "icon icon-arrow-down-after",
         }
         if ascending_title_text is not None:
-            attrs['title'] = ascending_title_text
+            attrs["title"] = ascending_title_text
 
-    attrs_string = format_html_join(' ', '{}="{}"', attrs.items())
+    attrs_string = format_html_join(" ", '{}="{}"', attrs.items())
 
     return format_html(
         # need whitespace around label for correct positioning of arrow icon
-        '<a {attrs}> {label} </a>',
-        attrs=attrs_string, label=label
+        "<a {attrs}> {label} </a>",
+        attrs=attrs_string,
+        label=label,
     )
 
 
 @register.simple_tag(takes_context=True)
-def pagination_querystring(context, page_number, page_key='p'):
+def pagination_querystring(context, page_number, page_key="p"):
     """
     Print out a querystring with an updated page number:
 
@@ -453,10 +502,10 @@ def pagination_querystring(context, page_number, page_key='p'):
     return querystring(context, **{page_key: page_number})
 
 
-@register.inclusion_tag("wagtailadmin/pages/listing/_pagination.html",
-                        takes_context=True)
-def paginate(context, page, base_url='', page_key='p',
-             classnames=''):
+@register.inclusion_tag(
+    "wagtailadmin/pages/listing/_pagination.html", takes_context=True
+)
+def paginate(context, page, base_url="", page_key="p", classnames=""):
     """
     Print pagination previous/next links, and the page count. Take the
     following arguments:
@@ -476,22 +525,21 @@ def paginate(context, page, base_url='', page_key='p',
     classnames
         Extra classes to add to the next/previous links.
     """
-    request = context['request']
+    request = context["request"]
     return {
-        'base_url': base_url,
-        'classnames': classnames,
-        'request': request,
-        'page': page,
-        'page_key': page_key,
-        'paginator': page.paginator,
+        "base_url": base_url,
+        "classnames": classnames,
+        "request": request,
+        "page": page,
+        "page_key": page_key,
+        "paginator": page.paginator,
     }
 
 
-@register.inclusion_tag("wagtailadmin/pages/listing/_buttons.html",
-                        takes_context=True)
+@register.inclusion_tag("wagtailadmin/pages/listing/_buttons.html", takes_context=True)
 def page_listing_buttons(context, page, page_perms, is_parent=False):
     next_url = context.request.path
-    button_hooks = hooks.get_hooks('register_page_listing_buttons')
+    button_hooks = hooks.get_hooks("register_page_listing_buttons")
 
     buttons = []
     for hook in button_hooks:
@@ -499,17 +547,18 @@ def page_listing_buttons(context, page, page_perms, is_parent=False):
 
     buttons.sort()
 
-    for hook in hooks.get_hooks('construct_page_listing_buttons'):
+    for hook in hooks.get_hooks("construct_page_listing_buttons"):
         hook(buttons, page, page_perms, is_parent, context)
 
-    return {'page': page, 'buttons': buttons}
+    return {"page": page, "buttons": buttons}
 
 
-@register.inclusion_tag("wagtailadmin/pages/listing/_button_with_dropdown.html",
-                        takes_context=True)
+@register.inclusion_tag(
+    "wagtailadmin/pages/listing/_button_with_dropdown.html", takes_context=True
+)
 def page_header_buttons(context, page, page_perms):
     next_url = context.request.path
-    button_hooks = hooks.get_hooks('register_page_header_buttons')
+    button_hooks = hooks.get_hooks("register_page_header_buttons")
 
     buttons = []
     for hook in button_hooks:
@@ -517,18 +566,19 @@ def page_header_buttons(context, page, page_perms):
 
     buttons.sort()
     return {
-        'page': page,
-        'buttons': buttons,
-        'title': 'Secondary actions menu',
-        'button_classes': ['c-dropdown__icon'],
+        "page": page,
+        "buttons": buttons,
+        "title": "Secondary actions menu",
+        "button_classes": ["c-dropdown__icon"],
     }
 
 
-@register.inclusion_tag("wagtailadmin/pages/listing/_buttons.html",
-                        takes_context=True)
+@register.inclusion_tag("wagtailadmin/pages/listing/_buttons.html", takes_context=True)
 def bulk_action_choices(context, app_label, model_name):
 
-    bulk_actions_list = list(bulk_action_registry.get_bulk_actions_for_model(app_label, model_name))
+    bulk_actions_list = list(
+        bulk_action_registry.get_bulk_actions_for_model(app_label, model_name)
+    )
     bulk_actions_list.sort(key=lambda x: x.action_priority)
 
     bulk_action_more_list = []
@@ -536,56 +586,67 @@ def bulk_action_choices(context, app_label, model_name):
         bulk_action_more_list = bulk_actions_list[4:]
         bulk_actions_list = bulk_actions_list[:4]
 
-    next_url = get_valid_next_url_from_request(context['request'])
+    next_url = get_valid_next_url_from_request(context["request"])
     if not next_url:
-        next_url = context['request'].path
+        next_url = context["request"].path
 
     bulk_action_buttons = [
         PageListingButton(
             action.display_name,
-            reverse('wagtail_bulk_action', args=[app_label, model_name, action.action_type]) + '?' + urlencode({'next': next_url}),
-            attrs={'aria-label': action.aria_label},
+            reverse(
+                "wagtail_bulk_action", args=[app_label, model_name, action.action_type]
+            )
+            + "?"
+            + urlencode({"next": next_url}),
+            attrs={"aria-label": action.aria_label},
             priority=action.action_priority,
-            classes=action.classes | {'bulk-action-btn'},
-        ) for action in bulk_actions_list
+            classes=action.classes | {"bulk-action-btn"},
+        )
+        for action in bulk_actions_list
     ]
 
     if bulk_action_more_list:
         more_button = ButtonWithDropdown(
             label=_("More"),
-            attrs={
-                'title': _("View more bulk actions")
-            },
-            classes={'bulk-actions-more', 'dropup'},
-            button_classes={'button', 'button-small'},
-            buttons_data=[{
-                'label': action.display_name,
-                'url': reverse('wagtail_bulk_action', args=[app_label, model_name, action.action_type]) + '?' + urlencode({'next': next_url}),
-                'attrs': {'aria-label': action.aria_label},
-                'priority': action.action_priority,
-                'classes': {'bulk-action-btn'},
-            } for action in bulk_action_more_list]
+            attrs={"title": _("View more bulk actions")},
+            classes={"bulk-actions-more", "dropup"},
+            button_classes={"button", "button-small"},
+            buttons_data=[
+                {
+                    "label": action.display_name,
+                    "url": reverse(
+                        "wagtail_bulk_action",
+                        args=[app_label, model_name, action.action_type],
+                    )
+                    + "?"
+                    + urlencode({"next": next_url}),
+                    "attrs": {"aria-label": action.aria_label},
+                    "priority": action.action_priority,
+                    "classes": {"bulk-action-btn"},
+                }
+                for action in bulk_action_more_list
+            ],
         )
         more_button.is_parent = True
         bulk_action_buttons.append(more_button)
 
-    return {'buttons': bulk_action_buttons}
+    return {"buttons": bulk_action_buttons}
 
 
 @register.simple_tag
 def message_tags(message):
     level_tag = MESSAGE_TAGS.get(message.level)
     if message.extra_tags and level_tag:
-        return message.extra_tags + ' ' + level_tag
+        return message.extra_tags + " " + level_tag
     elif message.extra_tags:
         return message.extra_tags
     elif level_tag:
         return level_tag
     else:
-        return ''
+        return ""
 
 
-@register.filter('abs')
+@register.filter("abs")
 def _abs(val):
     return abs(val)
 
@@ -603,15 +664,19 @@ def avatar_url(user, size=50, gravatar_only=False):
     Example usage: {% avatar_url request.user 50 %}
     """
 
-    if not gravatar_only and hasattr(user, 'wagtail_userprofile') and user.wagtail_userprofile.avatar:
+    if (
+        not gravatar_only
+        and hasattr(user, "wagtail_userprofile")
+        and user.wagtail_userprofile.avatar
+    ):
         return user.wagtail_userprofile.avatar.url
 
-    if hasattr(user, 'email'):
+    if hasattr(user, "email"):
         gravatar_url = get_gravatar_url(user.email, size=size)
         if gravatar_url is not None:
             return gravatar_url
 
-    return versioned_static_func('wagtailadmin/images/default-user-avatar.png')
+    return versioned_static_func("wagtailadmin/images/default-user-avatar.png")
 
 
 @register.simple_tag
@@ -638,7 +703,7 @@ def versioned_static(path):
 
 
 @register.inclusion_tag("wagtailadmin/shared/icon.html", takes_context=False)
-def icon(name=None, class_name='icon', title=None, wrapped=False):
+def icon(name=None, class_name="icon", title=None, wrapped=False):
     """
     Abstracts away the actual icon implementation.
 
@@ -655,12 +720,7 @@ def icon(name=None, class_name='icon', title=None, wrapped=False):
     if not name:
         raise ValueError("You must supply an icon name")
 
-    return {
-        'name': name,
-        'class_name': class_name,
-        'title': title,
-        'wrapped': wrapped
-    }
+    return {"name": name, "class_name": class_name, "title": title, "wrapped": wrapped}
 
 
 @register.filter()
@@ -671,14 +731,14 @@ def timesince_simple(d):
     1 week, 1 day ago -> 1 week ago
     0 minutes ago -> just now
     """
-    time_period = timesince(d).split(',')[0]
-    if time_period == avoid_wrapping(_('0 minutes')):
+    time_period = timesince(d).split(",")[0]
+    if time_period == avoid_wrapping(_("0 minutes")):
         return _("Just now")
-    return _("%(time_period)s ago") % {'time_period': time_period}
+    return _("%(time_period)s ago") % {"time_period": time_period}
 
 
 @register.simple_tag
-def timesince_last_update(last_update, time_prefix='', use_shorthand=True):
+def timesince_last_update(last_update, time_prefix="", use_shorthand=True):
     """
     Returns:
          - the time of update if last_update is today, if any prefix is supplied, the output will use it
@@ -691,13 +751,16 @@ def timesince_last_update(last_update, time_prefix='', use_shorthand=True):
         else:
             time_str = last_update.strftime("%H:%M")
 
-        return time_str if not time_prefix else '%(prefix)s %(formatted_time)s' % {
-            'prefix': time_prefix, 'formatted_time': time_str
-        }
+        return (
+            time_str
+            if not time_prefix
+            else "%(prefix)s %(formatted_time)s"
+            % {"prefix": time_prefix, "formatted_time": time_str}
+        )
     else:
         if use_shorthand:
             return timesince_simple(last_update)
-        return _("%(time_period)s ago") % {'time_period': timesince(last_update)}
+        return _("%(time_period)s ago") % {"time_period": timesince(last_update)}
 
 
 @register.simple_tag
@@ -721,7 +784,7 @@ def minimum_collection_depth(collections: QuerySet) -> int:
     Call this before beginning a loop through Collections that will
     use {% format_collection collection min_depth %}.
     """
-    return collections.aggregate(Min('depth'))['depth__min'] or 2
+    return collections.aggregate(Min("depth"))["depth__min"] or 2
 
 
 @register.filter
@@ -742,7 +805,7 @@ def user_display_name(user):
     except AttributeError:
         # we were passed None or something else that isn't a valid user object; return
         # empty string to replicate the behaviour of {{ user.get_full_name|default:user.get_username }}
-        return ''
+        return ""
 
 
 @register.filter
@@ -752,18 +815,20 @@ def format_content_type(content_type):
 
 @register.simple_tag
 def i18n_enabled():
-    return getattr(settings, 'WAGTAIL_I18N_ENABLED', False)
+    return getattr(settings, "WAGTAIL_I18N_ENABLED", False)
 
 
 @register.simple_tag
 def locales():
-    return json.dumps([
-        {
-            'code': locale.language_code,
-            'display_name': force_str(locale.get_display_name()),
-        }
-        for locale in Locale.objects.all()
-    ])
+    return json.dumps(
+        [
+            {
+                "code": locale.language_code,
+                "display_name": force_str(locale.get_display_name()),
+            }
+            for locale in Locale.objects.all()
+        ]
+    )
 
 
 @register.simple_tag
@@ -776,21 +841,21 @@ def locale_label_from_id(locale_id):
 
 @register.simple_tag()
 def slim_sidebar_enabled():
-    return getattr(settings, 'WAGTAIL_SLIM_SIDEBAR', True)
+    return getattr(settings, "WAGTAIL_SLIM_SIDEBAR", True)
 
 
 @register.simple_tag(takes_context=True)
 def sidebar_collapsed(context):
-    request = context.get('request')
-    collapsed = request.COOKIES.get('wagtail_sidebar_collapsed', '0')
-    if collapsed == '0':
+    request = context.get("request")
+    collapsed = request.COOKIES.get("wagtail_sidebar_collapsed", "0")
+    if collapsed == "0":
         return False
     return True
 
 
 @register.simple_tag(takes_context=True)
 def sidebar_props(context):
-    request = context['request']
+    request = context["request"]
     search_areas = admin_search_areas.search_items_for_request(request)
     if search_areas:
         search_area = search_areas[0]
@@ -798,25 +863,34 @@ def sidebar_props(context):
         search_area = None
 
     account_menu = [
-        sidebar.LinkMenuItem('account', _("Account"), reverse('wagtailadmin_account'), icon_name='user'),
-        sidebar.LinkMenuItem('logout', _("Log out"), reverse('wagtailadmin_logout'), icon_name='logout'),
+        sidebar.LinkMenuItem(
+            "account", _("Account"), reverse("wagtailadmin_account"), icon_name="user"
+        ),
+        sidebar.LinkMenuItem(
+            "logout", _("Log out"), reverse("wagtailadmin_logout"), icon_name="logout"
+        ),
     ]
 
     modules = [
         sidebar.WagtailBrandingModule(),
         sidebar.SearchModule(search_area) if search_area else None,
-        sidebar.MainMenuModule(admin_menu.render_component(request), account_menu, request.user),
+        sidebar.MainMenuModule(
+            admin_menu.render_component(request), account_menu, request.user
+        ),
     ]
     modules = [module for module in modules if module is not None]
 
-    return json_script({
-        'modules': JSContext().pack(modules),
-    }, element_id="wagtail-sidebar-props")
+    return json_script(
+        {
+            "modules": JSContext().pack(modules),
+        },
+        element_id="wagtail-sidebar-props",
+    )
 
 
 @register.simple_tag
 def get_comments_enabled():
-    return getattr(settings, 'WAGTAILADMIN_COMMENTS_ENABLED', True)
+    return getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True)
 
 
 @register.simple_tag
@@ -825,12 +899,12 @@ def resolve_url(url):
     # name, or a direct URL path, return it as a direct URL path. On failure (or being passed
     # an empty / None value), return empty string
     if not url:
-        return ''
+        return ""
 
     try:
         return resolve_url_func(url)
     except NoReverseMatch:
-        return ''
+        return ""
 
 
 @register.simple_tag(takes_context=True)
@@ -841,8 +915,8 @@ def component(context, obj, fallback_render_method=False):
     # called instead (with no arguments) - this is to provide deprecation path for things that have
     # been newly upgraded to use the component pattern.
 
-    has_render_html_method = hasattr(obj, 'render_html')
-    if fallback_render_method and not has_render_html_method and hasattr(obj, 'render'):
+    has_render_html_method = hasattr(obj, "render_html")
+    if fallback_render_method and not has_render_html_method and hasattr(obj, "render"):
         return obj.render()
     elif not has_render_html_method:
         raise ValueError("Cannot render %r as a component" % (obj,))

+ 31 - 20
wagtail/admin/templatetags/wagtailuserbar.py

@@ -3,13 +3,17 @@ from django.template.loader import render_to_string
 from django.utils import translation
 
 from wagtail.admin.userbar import (
-    AddPageItem, AdminItem, ApproveModerationEditPageItem, EditPageItem, ExplorePageItem,
-    RejectModerationEditPageItem)
+    AddPageItem,
+    AdminItem,
+    ApproveModerationEditPageItem,
+    EditPageItem,
+    ExplorePageItem,
+    RejectModerationEditPageItem,
+)
 from wagtail.core import hooks
 from wagtail.core.models import PAGE_TEMPLATE_VAR, Page, PageRevision
 from wagtail.users.models import UserProfile
 
-
 register = template.Library()
 
 
@@ -18,7 +22,7 @@ def get_page_instance(context):
     Given a template context, try and find a Page variable in the common
     places. Returns None if a page can not be found.
     """
-    possible_names = [PAGE_TEMPLATE_VAR, 'self']
+    possible_names = [PAGE_TEMPLATE_VAR, "self"]
     for name in possible_names:
         if name in context:
             page = context[name]
@@ -27,22 +31,22 @@ def get_page_instance(context):
 
 
 @register.simple_tag(takes_context=True)
-def wagtailuserbar(context, position='bottom-right'):
+def wagtailuserbar(context, position="bottom-right"):
     # Find request object
     try:
-        request = context['request']
+        request = context["request"]
     except KeyError:
-        return ''
+        return ""
 
     # Don't render without a user because we can't check their permissions
     try:
         user = request.user
     except AttributeError:
-        return ''
+        return ""
 
     # Don't render if user doesn't have permission to access the admin area
-    if not user.has_perm('wagtailadmin.access_admin'):
-        return ''
+    if not user.has_perm("wagtailadmin.access_admin"):
+        return ""
 
     # Render the userbar using the user's preferred admin language
     userprofile = UserProfile.get_for_user(user)
@@ -60,8 +64,12 @@ def wagtailuserbar(context, position='bottom-right'):
                     AdminItem(),
                     ExplorePageItem(PageRevision.objects.get(id=revision_id).page),
                     EditPageItem(PageRevision.objects.get(id=revision_id).page),
-                    ApproveModerationEditPageItem(PageRevision.objects.get(id=revision_id)),
-                    RejectModerationEditPageItem(PageRevision.objects.get(id=revision_id)),
+                    ApproveModerationEditPageItem(
+                        PageRevision.objects.get(id=revision_id)
+                    ),
+                    RejectModerationEditPageItem(
+                        PageRevision.objects.get(id=revision_id)
+                    ),
                 ]
             else:
                 # Not a revision
@@ -75,7 +83,7 @@ def wagtailuserbar(context, position='bottom-right'):
             # Not a page.
             items = [AdminItem()]
 
-        for fn in hooks.get_hooks('construct_wagtail_userbar'):
+        for fn in hooks.get_hooks("construct_wagtail_userbar"):
             fn(request, items)
 
         # Render the items
@@ -85,10 +93,13 @@ def wagtailuserbar(context, position='bottom-right'):
         rendered_items = [item for item in rendered_items if item]
 
         # Render the userbar items
-        return render_to_string('wagtailadmin/userbar/base.html', {
-            'request': request,
-            'items': rendered_items,
-            'position': position,
-            'page': page,
-            'revision_id': revision_id
-        })
+        return render_to_string(
+            "wagtailadmin/userbar/base.html",
+            {
+                "request": request,
+                "items": rendered_items,
+                "position": position,
+                "page": page,
+                "revision_id": revision_id,
+            },
+        )

+ 62 - 41
wagtail/admin/tests/api/test_documents.py

@@ -9,13 +9,13 @@ from .utils import AdminAPITestCase
 
 
 class TestAdminDocumentListing(AdminAPITestCase, TestDocumentListing):
-    fixtures = ['demosite.json']
+    fixtures = ["demosite.json"]
 
     def get_response(self, **params):
-        return self.client.get(reverse('wagtailadmin_api:documents:listing'), params)
+        return self.client.get(reverse("wagtailadmin_api:documents:listing"), params)
 
     def get_document_id_list(self, content):
-        return [document['id'] for document in content['items']]
+        return [document["id"] for document in content["items"]]
 
     # BASIC TESTS
 
@@ -23,92 +23,113 @@ class TestAdminDocumentListing(AdminAPITestCase, TestDocumentListing):
         response = self.get_response()
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response['Content-type'], 'application/json')
+        self.assertEqual(response["Content-type"], "application/json")
 
         # Will crash if the JSON is invalid
-        content = json.loads(response.content.decode('UTF-8'))
+        content = json.loads(response.content.decode("UTF-8"))
 
         # Check that the meta section is there
-        self.assertIn('meta', content)
-        self.assertIsInstance(content['meta'], dict)
+        self.assertIn("meta", content)
+        self.assertIsInstance(content["meta"], dict)
 
         # Check that the total count is there and correct
-        self.assertIn('total_count', content['meta'])
-        self.assertIsInstance(content['meta']['total_count'], int)
-        self.assertEqual(content['meta']['total_count'], Document.objects.count())
+        self.assertIn("total_count", content["meta"])
+        self.assertIsInstance(content["meta"]["total_count"], int)
+        self.assertEqual(content["meta"]["total_count"], Document.objects.count())
 
         # Check that the items section is there
-        self.assertIn('items', content)
-        self.assertIsInstance(content['items'], list)
+        self.assertIn("items", content)
+        self.assertIsInstance(content["items"], list)
 
         # Check that each document has a meta section with type, detail_url and tags attributes
-        for document in content['items']:
-            self.assertIn('meta', document)
-            self.assertIsInstance(document['meta'], dict)
-            self.assertEqual(set(document['meta'].keys()), {'type', 'detail_url', 'download_url', 'tags'})
+        for document in content["items"]:
+            self.assertIn("meta", document)
+            self.assertIsInstance(document["meta"], dict)
+            self.assertEqual(
+                set(document["meta"].keys()),
+                {"type", "detail_url", "download_url", "tags"},
+            )
 
             # Type should always be wagtaildocs.Document
-            self.assertEqual(document['meta']['type'], 'wagtaildocs.Document')
+            self.assertEqual(document["meta"]["type"], "wagtaildocs.Document")
 
             # Check detail_url
-            self.assertEqual(document['meta']['detail_url'], 'http://localhost/admin/api/main/documents/%d/' % document['id'])
+            self.assertEqual(
+                document["meta"]["detail_url"],
+                "http://localhost/admin/api/main/documents/%d/" % document["id"],
+            )
 
             # Check download_url
-            self.assertTrue(document['meta']['download_url'].startswith('http://localhost/documents/%d/' % document['id']))
+            self.assertTrue(
+                document["meta"]["download_url"].startswith(
+                    "http://localhost/documents/%d/" % document["id"]
+                )
+            )
 
     # FIELDS
 
     def test_fields_default(self):
         response = self.get_response()
-        content = json.loads(response.content.decode('UTF-8'))
+        content = json.loads(response.content.decode("UTF-8"))
 
-        for document in content['items']:
-            self.assertEqual(set(document.keys()), {'id', 'meta', 'title'})
-            self.assertEqual(set(document['meta'].keys()), {'type', 'detail_url', 'download_url', 'tags'})
+        for document in content["items"]:
+            self.assertEqual(set(document.keys()), {"id", "meta", "title"})
+            self.assertEqual(
+                set(document["meta"].keys()),
+                {"type", "detail_url", "download_url", "tags"},
+            )
 
 
 class TestAdminDocumentDetail(AdminAPITestCase, TestDocumentDetail):
-    fixtures = ['demosite.json']
+    fixtures = ["demosite.json"]
 
     def get_response(self, image_id, **params):
-        return self.client.get(reverse('wagtailadmin_api:documents:detail', args=(image_id, )), params)
+        return self.client.get(
+            reverse("wagtailadmin_api:documents:detail", args=(image_id,)), params
+        )
 
     def test_basic(self):
         response = self.get_response(1)
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response['Content-type'], 'application/json')
+        self.assertEqual(response["Content-type"], "application/json")
 
         # Will crash if the JSON is invalid
-        content = json.loads(response.content.decode('UTF-8'))
+        content = json.loads(response.content.decode("UTF-8"))
 
         # Check the id field
-        self.assertIn('id', content)
-        self.assertEqual(content['id'], 1)
+        self.assertIn("id", content)
+        self.assertEqual(content["id"], 1)
 
         # Check that the meta section is there
-        self.assertIn('meta', content)
-        self.assertIsInstance(content['meta'], dict)
+        self.assertIn("meta", content)
+        self.assertIsInstance(content["meta"], dict)
 
         # Check the meta type
-        self.assertIn('type', content['meta'])
-        self.assertEqual(content['meta']['type'], 'wagtaildocs.Document')
+        self.assertIn("type", content["meta"])
+        self.assertEqual(content["meta"]["type"], "wagtaildocs.Document")
 
         # Check the meta detail_url
-        self.assertIn('detail_url', content['meta'])
-        self.assertEqual(content['meta']['detail_url'], 'http://localhost/admin/api/main/documents/1/')
+        self.assertIn("detail_url", content["meta"])
+        self.assertEqual(
+            content["meta"]["detail_url"],
+            "http://localhost/admin/api/main/documents/1/",
+        )
 
         # Check the meta download_url
-        self.assertIn('download_url', content['meta'])
-        self.assertEqual(content['meta']['download_url'], 'http://localhost/documents/1/wagtail_by_markyharky.jpg')
+        self.assertIn("download_url", content["meta"])
+        self.assertEqual(
+            content["meta"]["download_url"],
+            "http://localhost/documents/1/wagtail_by_markyharky.jpg",
+        )
 
         # Check the title field
-        self.assertIn('title', content)
-        self.assertEqual(content['title'], "Wagtail by mark Harkin")
+        self.assertIn("title", content)
+        self.assertEqual(content["title"], "Wagtail by mark Harkin")
 
         # Check the tags field
-        self.assertIn('tags', content['meta'])
-        self.assertEqual(content['meta']['tags'], [])
+        self.assertIn("tags", content["meta"])
+        self.assertEqual(content["meta"]["tags"], [])
 
 
 # Overwrite imported test cases do Django doesn't run them

+ 144 - 92
wagtail/admin/tests/api/test_images.py

@@ -10,13 +10,13 @@ from .utils import AdminAPITestCase
 
 
 class TestAdminImageListing(AdminAPITestCase, TestImageListing):
-    fixtures = ['demosite.json']
+    fixtures = ["demosite.json"]
 
     def get_response(self, **params):
-        return self.client.get(reverse('wagtailadmin_api:images:listing'), params)
+        return self.client.get(reverse("wagtailadmin_api:images:listing"), params)
 
     def get_image_id_list(self, content):
-        return [image['id'] for image in content['items']]
+        return [image["id"] for image in content["items"]]
 
     # BASIC TESTS
 
@@ -24,160 +24,212 @@ class TestAdminImageListing(AdminAPITestCase, TestImageListing):
         response = self.get_response()
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response['Content-type'], 'application/json')
+        self.assertEqual(response["Content-type"], "application/json")
 
         # Will crash if the JSON is invalid
-        content = json.loads(response.content.decode('UTF-8'))
+        content = json.loads(response.content.decode("UTF-8"))
 
         # Check that the meta section is there
-        self.assertIn('meta', content)
-        self.assertIsInstance(content['meta'], dict)
+        self.assertIn("meta", content)
+        self.assertIsInstance(content["meta"], dict)
 
         # Check that the total count is there and correct
-        self.assertIn('total_count', content['meta'])
-        self.assertIsInstance(content['meta']['total_count'], int)
-        self.assertEqual(content['meta']['total_count'], get_image_model().objects.count())
+        self.assertIn("total_count", content["meta"])
+        self.assertIsInstance(content["meta"]["total_count"], int)
+        self.assertEqual(
+            content["meta"]["total_count"], get_image_model().objects.count()
+        )
 
         # Check that the items section is there
-        self.assertIn('items', content)
-        self.assertIsInstance(content['items'], list)
+        self.assertIn("items", content)
+        self.assertIsInstance(content["items"], list)
 
         # Check that each image has a meta section with type, detail_url and tags attributes
-        for image in content['items']:
-            self.assertIn('meta', image)
-            self.assertIsInstance(image['meta'], dict)
-            self.assertEqual(set(image['meta'].keys()), {'type', 'detail_url', 'tags', 'download_url'})
+        for image in content["items"]:
+            self.assertIn("meta", image)
+            self.assertIsInstance(image["meta"], dict)
+            self.assertEqual(
+                set(image["meta"].keys()),
+                {"type", "detail_url", "tags", "download_url"},
+            )
 
             # Type should always be wagtailimages.Image
-            self.assertEqual(image['meta']['type'], 'wagtailimages.Image')
+            self.assertEqual(image["meta"]["type"], "wagtailimages.Image")
 
             # Check detail url
-            self.assertEqual(image['meta']['detail_url'], 'http://localhost/admin/api/main/images/%d/' % image['id'])
+            self.assertEqual(
+                image["meta"]["detail_url"],
+                "http://localhost/admin/api/main/images/%d/" % image["id"],
+            )
 
     #  FIELDS
 
     def test_fields_default(self):
         response = self.get_response()
-        content = json.loads(response.content.decode('UTF-8'))
-
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'meta', 'title', 'width', 'height', 'thumbnail'})
-            self.assertEqual(set(image['meta'].keys()), {'type', 'detail_url', 'download_url', 'tags'})
+        content = json.loads(response.content.decode("UTF-8"))
+
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()),
+                {"id", "meta", "title", "width", "height", "thumbnail"},
+            )
+            self.assertEqual(
+                set(image["meta"].keys()),
+                {"type", "detail_url", "download_url", "tags"},
+            )
 
     def test_fields(self):
-        response = self.get_response(fields='width,height')
-        content = json.loads(response.content.decode('UTF-8'))
-
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'meta', 'title', 'width', 'height', 'thumbnail'})
-            self.assertEqual(set(image['meta'].keys()), {'type', 'detail_url', 'download_url', 'tags'})
+        response = self.get_response(fields="width,height")
+        content = json.loads(response.content.decode("UTF-8"))
+
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()),
+                {"id", "meta", "title", "width", "height", "thumbnail"},
+            )
+            self.assertEqual(
+                set(image["meta"].keys()),
+                {"type", "detail_url", "download_url", "tags"},
+            )
 
     def test_remove_fields(self):
-        response = self.get_response(fields='-title')
-        content = json.loads(response.content.decode('UTF-8'))
+        response = self.get_response(fields="-title")
+        content = json.loads(response.content.decode("UTF-8"))
 
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'meta', 'width', 'height', 'thumbnail'})
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()), {"id", "meta", "width", "height", "thumbnail"}
+            )
 
     def test_remove_meta_fields(self):
-        response = self.get_response(fields='-tags')
-        content = json.loads(response.content.decode('UTF-8'))
-
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'meta', 'title', 'width', 'height', 'thumbnail'})
-            self.assertEqual(set(image['meta'].keys()), {'type', 'detail_url', 'download_url'})
+        response = self.get_response(fields="-tags")
+        content = json.loads(response.content.decode("UTF-8"))
+
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()),
+                {"id", "meta", "title", "width", "height", "thumbnail"},
+            )
+            self.assertEqual(
+                set(image["meta"].keys()), {"type", "detail_url", "download_url"}
+            )
 
     def test_remove_all_meta_fields(self):
-        response = self.get_response(fields='-type,-detail_url,-tags')
-        content = json.loads(response.content.decode('UTF-8'))
+        response = self.get_response(fields="-type,-detail_url,-tags")
+        content = json.loads(response.content.decode("UTF-8"))
 
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'title', 'width', 'height', 'thumbnail', 'meta'})
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()),
+                {"id", "title", "width", "height", "thumbnail", "meta"},
+            )
 
     def test_remove_id_field(self):
-        response = self.get_response(fields='-id')
-        content = json.loads(response.content.decode('UTF-8'))
+        response = self.get_response(fields="-id")
+        content = json.loads(response.content.decode("UTF-8"))
 
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'meta', 'title', 'width', 'height', 'thumbnail'})
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()), {"meta", "title", "width", "height", "thumbnail"}
+            )
 
     def test_all_fields(self):
-        response = self.get_response(fields='*')
-        content = json.loads(response.content.decode('UTF-8'))
-
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'meta', 'title', 'width', 'height', 'thumbnail'})
-            self.assertEqual(set(image['meta'].keys()), {'type', 'detail_url', 'tags', 'download_url'})
+        response = self.get_response(fields="*")
+        content = json.loads(response.content.decode("UTF-8"))
+
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()),
+                {"id", "meta", "title", "width", "height", "thumbnail"},
+            )
+            self.assertEqual(
+                set(image["meta"].keys()),
+                {"type", "detail_url", "tags", "download_url"},
+            )
 
     def test_all_fields_then_remove_something(self):
-        response = self.get_response(fields='*,-title,-tags')
-        content = json.loads(response.content.decode('UTF-8'))
+        response = self.get_response(fields="*,-title,-tags")
+        content = json.loads(response.content.decode("UTF-8"))
 
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'meta', 'width', 'height', 'thumbnail'})
-            self.assertEqual(set(image['meta'].keys()), {'type', 'detail_url', 'download_url'})
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()), {"id", "meta", "width", "height", "thumbnail"}
+            )
+            self.assertEqual(
+                set(image["meta"].keys()), {"type", "detail_url", "download_url"}
+            )
 
     def test_fields_tags(self):
-        response = self.get_response(fields='tags')
-        content = json.loads(response.content.decode('UTF-8'))
+        response = self.get_response(fields="tags")
+        content = json.loads(response.content.decode("UTF-8"))
 
-        for image in content['items']:
-            self.assertEqual(set(image.keys()), {'id', 'meta', 'title', 'width', 'height', 'thumbnail'})
-            self.assertEqual(set(image['meta'].keys()), {'type', 'detail_url', 'tags', 'download_url'})
-            self.assertIsInstance(image['meta']['tags'], list)
+        for image in content["items"]:
+            self.assertEqual(
+                set(image.keys()),
+                {"id", "meta", "title", "width", "height", "thumbnail"},
+            )
+            self.assertEqual(
+                set(image["meta"].keys()),
+                {"type", "detail_url", "tags", "download_url"},
+            )
+            self.assertIsInstance(image["meta"]["tags"], list)
 
 
 class TestAdminImageDetail(AdminAPITestCase, TestImageDetail):
-    fixtures = ['demosite.json']
+    fixtures = ["demosite.json"]
 
     def get_response(self, image_id, **params):
-        return self.client.get(reverse('wagtailadmin_api:images:detail', args=(image_id, )), params)
+        return self.client.get(
+            reverse("wagtailadmin_api:images:detail", args=(image_id,)), params
+        )
 
     def test_basic(self):
         response = self.get_response(5)
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response['Content-type'], 'application/json')
+        self.assertEqual(response["Content-type"], "application/json")
 
         # Will crash if the JSON is invalid
-        content = json.loads(response.content.decode('UTF-8'))
+        content = json.loads(response.content.decode("UTF-8"))
 
         # Check the id field
-        self.assertIn('id', content)
-        self.assertEqual(content['id'], 5)
+        self.assertIn("id", content)
+        self.assertEqual(content["id"], 5)
 
         # Check that the meta section is there
-        self.assertIn('meta', content)
-        self.assertIsInstance(content['meta'], dict)
+        self.assertIn("meta", content)
+        self.assertIsInstance(content["meta"], dict)
 
         # Check the meta type
-        self.assertIn('type', content['meta'])
-        self.assertEqual(content['meta']['type'], 'wagtailimages.Image')
+        self.assertIn("type", content["meta"])
+        self.assertEqual(content["meta"]["type"], "wagtailimages.Image")
 
         # Check the meta detail_url
-        self.assertIn('detail_url', content['meta'])
-        self.assertEqual(content['meta']['detail_url'], 'http://localhost/admin/api/main/images/5/')
+        self.assertIn("detail_url", content["meta"])
+        self.assertEqual(
+            content["meta"]["detail_url"], "http://localhost/admin/api/main/images/5/"
+        )
 
         # Check the thumbnail
 
         # Note: This is None because the source image doesn't exist
         #       See test_thumbnail below for working example
-        self.assertIn('thumbnail', content)
-        self.assertEqual(content['thumbnail'], {'error': 'SourceImageIOError'})
+        self.assertIn("thumbnail", content)
+        self.assertEqual(content["thumbnail"], {"error": "SourceImageIOError"})
 
         # Check the title field
-        self.assertIn('title', content)
-        self.assertEqual(content['title'], "James Joyce")
+        self.assertIn("title", content)
+        self.assertEqual(content["title"], "James Joyce")
 
         # Check the width and height fields
-        self.assertIn('width', content)
-        self.assertIn('height', content)
-        self.assertEqual(content['width'], 500)
-        self.assertEqual(content['height'], 392)
+        self.assertIn("width", content)
+        self.assertIn("height", content)
+        self.assertEqual(content["width"], 500)
+        self.assertEqual(content["height"], 392)
 
         # Check the tags field
-        self.assertIn('tags', content['meta'])
-        self.assertEqual(content['meta']['tags'], [])
+        self.assertIn("tags", content["meta"])
+        self.assertEqual(content["meta"]["tags"], [])
 
     def test_thumbnail(self):
         # Add a new image with source file
@@ -187,15 +239,15 @@ class TestAdminImageDetail(AdminAPITestCase, TestImageDetail):
         )
 
         response = self.get_response(image.id)
-        content = json.loads(response.content.decode('UTF-8'))
+        content = json.loads(response.content.decode("UTF-8"))
 
-        self.assertIn('thumbnail', content)
-        self.assertEqual(content['thumbnail']['width'], 165)
-        self.assertEqual(content['thumbnail']['height'], 123)
-        self.assertTrue(content['thumbnail']['url'].startswith('/media/images/test'))
+        self.assertIn("thumbnail", content)
+        self.assertEqual(content["thumbnail"]["width"], 165)
+        self.assertEqual(content["thumbnail"]["height"], 123)
+        self.assertTrue(content["thumbnail"]["url"].startswith("/media/images/test"))
 
         # Check that source_image_error didn't appear
-        self.assertNotIn('source_image_error', content['meta'])
+        self.assertNotIn("source_image_error", content["meta"])
 
 
 # Overwrite imported test cases do Django doesn't run them

Plik diff jest za duży
+ 485 - 264
wagtail/admin/tests/api/test_pages.py


+ 28 - 16
wagtail/admin/tests/benches.py

@@ -23,20 +23,28 @@ class BenchPageExplorerWith50LargePages(Benchmark, WagtailTestUtils, TestCase):
         Site.objects.create(is_default_site=True, root_page=self.root_page)
 
         # Create a large piece of body text
-        body = '[' + ','.join(['{"type": "text", "value": "%s"}' % ('foo' * 2000)] * 100) + ']'
+        body = (
+            "["
+            + ",".join(['{"type": "text", "value": "%s"}' % ("foo" * 2000)] * 100)
+            + "]"
+        )
 
         # Create 50 simple pages with long content fields
         for i in range(50):
-            self.root_page.add_child(instance=StreamPage(
-                title="Page {}".format(i + 1),
-                slug=str(i + 1),
-                body=body,
-            ))
+            self.root_page.add_child(
+                instance=StreamPage(
+                    title="Page {}".format(i + 1),
+                    slug=str(i + 1),
+                    body=body,
+                )
+            )
 
         self.login()
 
     def bench(self):
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check the response was good
         self.assertEqual(response.status_code, 200)
@@ -61,19 +69,23 @@ class BenchPageExplorerWithCustomURLPages(Benchmark, WagtailTestUtils, TestCase)
 
         # Create 50 blog pages
         for i in range(50):
-            self.root_page.add_child(instance=SingleEventPage(
-                title="Event {}".format(i + 1),
-                slug=str(i + 1),
-                date_from=timezone.now(),
-                audience="public",
-                location="reykjavik",
-                cost="cost",
-            ))
+            self.root_page.add_child(
+                instance=SingleEventPage(
+                    title="Event {}".format(i + 1),
+                    slug=str(i + 1),
+                    date_from=timezone.now(),
+                    audience="public",
+                    location="reykjavik",
+                    cost="cost",
+                )
+            )
 
         self.login()
 
     def bench(self):
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check the response was good
         self.assertEqual(response.status_code, 200)

+ 8 - 1
wagtail/admin/tests/pages/test_bulk_actions/test_bulk_action.py

@@ -11,6 +11,13 @@ class TestBulkActionDispatcher(TestCase, WagtailTestUtils):
         self.user = self.login()
 
     def test_bulk_action_invalid_action(self):
-        url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'ships', ))
+        url = reverse(
+            "wagtail_bulk_action",
+            args=(
+                "wagtailcore",
+                "page",
+                "ships",
+            ),
+        )
         response = self.client.get(url)
         self.assertEqual(response.status_code, 404)

+ 97 - 47
wagtail/admin/tests/pages/test_bulk_actions/test_bulk_delete.py

@@ -21,7 +21,9 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
 
         # Add child pages
         self.child_pages = [
-            SimplePage(title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"hello-{i}")
+            SimplePage(
+                title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"hello-{i}"
+            )
             for i in range(1, 5)
         ]
         # first three child pages will be deleted
@@ -32,20 +34,38 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
 
         # map of the form { page: [child_pages] } to be added
         self.grandchildren_pages = {
-            self.pages_to_be_deleted[0]: [SimplePage(title="Hello world!-a", slug="hello-world-a", content="hello-a")],
+            self.pages_to_be_deleted[0]: [
+                SimplePage(
+                    title="Hello world!-a", slug="hello-world-a", content="hello-a"
+                )
+            ],
             self.pages_to_be_deleted[1]: [
-                SimplePage(title="Hello world!-b", slug="hello-world-b", content="hello-b"),
-                SimplePage(title="Hello world!-c", slug="hello-world-c", content="hello-c")
-            ]
+                SimplePage(
+                    title="Hello world!-b", slug="hello-world-b", content="hello-b"
+                ),
+                SimplePage(
+                    title="Hello world!-c", slug="hello-world-c", content="hello-c"
+                ),
+            ],
         }
 
         for child_page, grandchild_pages in self.grandchildren_pages.items():
             for grandchild_page in grandchild_pages:
                 child_page.add_child(instance=grandchild_page)
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'delete', )) + '?'
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "delete",
+                ),
+            )
+            + "?"
+        )
         for child_page in self.pages_to_be_deleted:
-            self.url += f'&id={child_page.id}'
+            self.url += f"&id={child_page.id}"
 
         # Login
         self.user = self.login()
@@ -60,20 +80,20 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
         html = response.content.decode()
         for child_page in self.pages_to_be_deleted:
             # check if the pages to be deleted and number of descendant pages are displayed
-            needle = '<li>'
+            needle = "<li>"
             needle += '<a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">{page_title}</a>'.format(
-                edit_page_url=reverse('wagtailadmin_pages:edit', args=[child_page.id]),
-                page_title=child_page.title
+                edit_page_url=reverse("wagtailadmin_pages:edit", args=[child_page.id]),
+                page_title=child_page.title,
             )
             descendants = len(self.grandchildren_pages.get(child_page, []))
             if descendants:
-                needle += '<p>'
+                needle += "<p>"
                 if descendants == 1:
-                    needle += 'This will also delete one more subpage.'
+                    needle += "This will also delete one more subpage."
                 else:
-                    needle += f'This will also delete {descendants} more subpages.'
-                needle += '</p>'
-            needle += '</li>'
+                    needle += f"This will also delete {descendants} more subpages."
+                needle += "</p>"
+            needle += "</li>"
             self.assertInHTML(needle, html)
 
     def test_page_delete_specific_admin_title(self):
@@ -81,13 +101,15 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
         self.assertEqual(response.status_code, 200)
 
         # The number of pages to be deleted is shown on the delete confirmation page.
-        self.assertContains(response, f'Delete {len(self.pages_to_be_deleted)} pages')
+        self.assertContains(response, f"Delete {len(self.pages_to_be_deleted)} pages")
 
     def test_page_delete_bad_permissions(self):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
@@ -103,12 +125,20 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
 
         html = response.content.decode()
 
-        self.assertInHTML("<p>You don't have permission to delete these pages</p>", html)
+        self.assertInHTML(
+            "<p>You don't have permission to delete these pages</p>", html
+        )
 
         for child_page in self.pages_to_be_deleted:
-            self.assertInHTML('<li>{page_title}</li>'.format(page_title=child_page.title), html)
+            self.assertInHTML(
+                "<li>{page_title}</li>".format(page_title=child_page.title), html
+            )
 
-        self.assertTagInHTML('''<form action="{}" method="POST"></form>'''.format(self.url), html, count=0)
+        self.assertTagInHTML(
+            """<form action="{}" method="POST"></form>""".format(self.url),
+            html,
+            count=0,
+        )
 
     def test_bulk_delete_post(self):
         # Connect a mock signal handler to page_unpublished signal
@@ -122,7 +152,9 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
         self.assertEqual(response.status_code, 302)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
         # Check that the child pages to be deleted are gone
         for child_page in self.pages_to_be_deleted:
@@ -134,21 +166,25 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
 
         # Check that the page_unpublished signal was fired for all pages
         num_descendants = sum(len(i) for i in self.grandchildren_pages.values())
-        self.assertEqual(mock_handler.call_count, len(self.pages_to_be_deleted) + num_descendants)
+        self.assertEqual(
+            mock_handler.call_count, len(self.pages_to_be_deleted) + num_descendants
+        )
 
         i = 0
         for child_page in self.pages_to_be_deleted:
             mock_call = mock_handler.mock_calls[i][2]
             i += 1
-            self.assertEqual(mock_call['sender'], child_page.specific_class)
-            self.assertEqual(mock_call['instance'], child_page)
-            self.assertIsInstance(mock_call['instance'], child_page.specific_class)
+            self.assertEqual(mock_call["sender"], child_page.specific_class)
+            self.assertEqual(mock_call["instance"], child_page)
+            self.assertIsInstance(mock_call["instance"], child_page.specific_class)
             for grandchildren_page in self.grandchildren_pages.get(child_page, []):
                 mock_call = mock_handler.mock_calls[i][2]
                 i += 1
-                self.assertEqual(mock_call['sender'], grandchildren_page.specific_class)
-                self.assertEqual(mock_call['instance'], grandchildren_page)
-                self.assertIsInstance(mock_call['instance'], grandchildren_page.specific_class)
+                self.assertEqual(mock_call["sender"], grandchildren_page.specific_class)
+                self.assertEqual(mock_call["instance"], grandchildren_page)
+                self.assertIsInstance(
+                    mock_call["instance"], grandchildren_page.specific_class
+                )
 
     def test_bulk_delete_notlive_post(self):
         # Same as above, but this makes sure the page_unpublished signal is not fired
@@ -169,7 +205,9 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
         self.assertEqual(response.status_code, 302)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
         # Check that the child pages to be deleted are gone
         for child_page in self.pages_to_be_deleted:
@@ -181,7 +219,9 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
 
         # Check that the page_unpublished signal was not fired
         num_descendants = sum(len(v) for v in self.grandchildren_pages.values())
-        self.assertEqual(mock_handler.call_count, len(self.pages_to_be_deleted) + num_descendants - 1)
+        self.assertEqual(
+            mock_handler.call_count, len(self.pages_to_be_deleted) + num_descendants - 1
+        )
 
         # check that only signals for other pages are fired
         i = 0
@@ -189,15 +229,17 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
             if child_page.id != page_to_be_unpublished.id:
                 mock_call = mock_handler.mock_calls[i][2]
                 i += 1
-                self.assertEqual(mock_call['sender'], child_page.specific_class)
-                self.assertEqual(mock_call['instance'], child_page)
-                self.assertIsInstance(mock_call['instance'], child_page.specific_class)
+                self.assertEqual(mock_call["sender"], child_page.specific_class)
+                self.assertEqual(mock_call["instance"], child_page)
+                self.assertIsInstance(mock_call["instance"], child_page.specific_class)
             for grandchildren_page in self.grandchildren_pages.get(child_page, []):
                 mock_call = mock_handler.mock_calls[i][2]
                 i += 1
-                self.assertEqual(mock_call['sender'], grandchildren_page.specific_class)
-                self.assertEqual(mock_call['instance'], grandchildren_page)
-                self.assertIsInstance(mock_call['instance'], grandchildren_page.specific_class)
+                self.assertEqual(mock_call["sender"], grandchildren_page.specific_class)
+                self.assertEqual(mock_call["instance"], grandchildren_page)
+                self.assertIsInstance(
+                    mock_call["instance"], grandchildren_page.specific_class
+                )
 
     def test_subpage_deletion(self):
         # Connect mock signal handlers to page_unpublished, pre_delete and post_delete signals
@@ -225,7 +267,9 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
         self.assertEqual(response.status_code, 302)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
         # Check that the child pages to be deleted are gone
         for child_page in self.pages_to_be_deleted:
@@ -238,7 +282,9 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
         # Check that the subpages are also gone
         for grandchild_pages in self.grandchildren_pages.values():
             for grandchild_page in grandchild_pages:
-                self.assertFalse(SimplePage.objects.filter(id=grandchild_page.id).exists())
+                self.assertFalse(
+                    SimplePage.objects.filter(id=grandchild_page.id).exists()
+                )
 
         # Check that the signals were fired for all child and grandchild pages
         for child_page, grandchild_pages in self.grandchildren_pages.items():
@@ -246,23 +292,28 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
             self.assertIn((SimplePage, child_page.id), pre_delete_signals_received)
             self.assertIn((SimplePage, child_page.id), post_delete_signals_received)
             for grandchild_page in grandchild_pages:
-                self.assertIn((SimplePage, grandchild_page.id), unpublish_signals_received)
-                self.assertIn((SimplePage, grandchild_page.id), pre_delete_signals_received)
-                self.assertIn((SimplePage, grandchild_page.id), post_delete_signals_received)
+                self.assertIn(
+                    (SimplePage, grandchild_page.id), unpublish_signals_received
+                )
+                self.assertIn(
+                    (SimplePage, grandchild_page.id), pre_delete_signals_received
+                )
+                self.assertIn(
+                    (SimplePage, grandchild_page.id), post_delete_signals_received
+                )
 
         self.assertEqual(response.status_code, 302)
 
     def test_before_delete_page_hook(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'delete')
+            self.assertEqual(action_type, "delete")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
                 self.assertEqual(page.id, self.pages_to_be_deleted[i].id)
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_bulk_action', hook_func):
+        with self.register_hook("before_bulk_action", hook_func):
             response = self.client.post(self.url)
 
         self.assertEqual(response.status_code, 200)
@@ -273,9 +324,8 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
             self.assertTrue(SimplePage.objects.filter(id=child_page.id).exists())
 
     def test_after_delete_page_hook(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'delete')
+            self.assertEqual(action_type, "delete")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
@@ -283,7 +333,7 @@ class TestBulkDelete(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_bulk_action', hook_func):
+        with self.register_hook("after_bulk_action", hook_func):
             response = self.client.post(self.url)
 
         self.assertEqual(response.status_code, 200)

+ 150 - 81
wagtail/admin/tests/pages/test_bulk_actions/test_bulk_move.py

@@ -14,40 +14,56 @@ from wagtail.tests.utils import WagtailTestUtils
 
 
 class TestBulkMove(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
         # Find root page
         self.root_page = Page.objects.get(id=2)
 
         # Create three sections
-        self.section_a = SimplePage(title="Section A", slug="section-a", content="hello")
+        self.section_a = SimplePage(
+            title="Section A", slug="section-a", content="hello"
+        )
         self.root_page.add_child(instance=self.section_a)
 
-        self.section_b = SimplePage(title="Section B", slug="section-b", content="hello")
+        self.section_b = SimplePage(
+            title="Section B", slug="section-b", content="hello"
+        )
         self.root_page.add_child(instance=self.section_b)
 
-        self.section_c = SimplePage(title="Section C", slug="section-c", content="hello")
+        self.section_c = SimplePage(
+            title="Section C", slug="section-c", content="hello"
+        )
         self.root_page.add_child(instance=self.section_c)
 
         # Add test page A into section A
-        self.test_page_a = SimplePage(title="Hello world!", slug="hello-world-a", content="hello")
+        self.test_page_a = SimplePage(
+            title="Hello world!", slug="hello-world-a", content="hello"
+        )
         self.section_a.add_child(instance=self.test_page_a)
 
         # Add test page B into section C
-        self.test_page_b = SimplePage(title="Hello world!", slug="hello-world-b", content="hello")
+        self.test_page_b = SimplePage(
+            title="Hello world!", slug="hello-world-b", content="hello"
+        )
         self.section_c.add_child(instance=self.test_page_b)
 
         # Add test page B_1 into section C
-        self.test_page_b_1 = SimplePage(title="Hello world!", slug="hello-world-b-1", content="hello")
+        self.test_page_b_1 = SimplePage(
+            title="Hello world!", slug="hello-world-b-1", content="hello"
+        )
         self.section_c.add_child(instance=self.test_page_b_1)
 
         # Add test page A_1 into section C having same slug as test page A
-        self.test_page_a_1 = SimplePage(title="Hello world!", slug="hello-world-a", content="hello")
+        self.test_page_a_1 = SimplePage(
+            title="Hello world!", slug="hello-world-a", content="hello"
+        )
         self.section_c.add_child(instance=self.test_page_a_1)
 
         # Add unpublished page to the root with a child page
-        self.unpublished_page = SimplePage(title="Unpublished", slug="unpublished", content="hello")
+        self.unpublished_page = SimplePage(
+            title="Unpublished", slug="unpublished", content="hello"
+        )
         sub_page = SimplePage(title="Sub Page", slug="sub-page", content="child")
         self.root_page.add_child(instance=self.unpublished_page)
         self.unpublished_page.add_child(instance=sub_page)
@@ -58,7 +74,17 @@ class TestBulkMove(TestCase, WagtailTestUtils):
 
         self.pages_to_be_moved = [self.test_page_b, self.test_page_b_1]
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'move', )) + f'?id={self.test_page_b.id}&id={self.test_page_b_1.id}'
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "move",
+                ),
+            )
+            + f"?id={self.test_page_b.id}&id={self.test_page_b_1.id}"
+        )
 
         # Login
         self.user = self.login()
@@ -69,17 +95,24 @@ class TestBulkMove(TestCase, WagtailTestUtils):
 
         html = response.content.decode()
 
-        self.assertInHTML('<p>Are you sure you want to move these pages?</p>', html)
+        self.assertInHTML("<p>Are you sure you want to move these pages?</p>", html)
 
-        self.assertInHTML('<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">Hello world! (simple page)</a></li>'.format(
-            edit_page_url=reverse('wagtailadmin_pages:edit', args=[self.test_page_b.id]),
-        ), html)
+        self.assertInHTML(
+            '<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">Hello world! (simple page)</a></li>'.format(
+                edit_page_url=reverse(
+                    "wagtailadmin_pages:edit", args=[self.test_page_b.id]
+                ),
+            ),
+            html,
+        )
 
     def test_bulk_move_bad_permissions(self):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
@@ -93,15 +126,21 @@ class TestBulkMove(TestCase, WagtailTestUtils):
         self.assertInHTML("<p>You don't have permission to move these pages</p>", html)
 
         for child_page in self.pages_to_be_moved:
-            self.assertInHTML('<li>{page_title}</li>'.format(page_title=child_page.title), html)
-
-        self.assertTagInHTML('''<form action="{}" method="POST"></form>'''.format(self.url), html, count=0)
+            self.assertInHTML(
+                "<li>{page_title}</li>".format(page_title=child_page.title), html
+            )
+
+        self.assertTagInHTML(
+            """<form action="{}" method="POST"></form>""".format(self.url),
+            html,
+            count=0,
+        )
 
     def test_user_without_bulk_delete_permission_can_move(self):
         # to verify that a user without bulk delete permission is able to move a page with a child page
 
         self.client.logout()
-        user = get_user_model().objects.get(email='siteeditor@example.com')
+        user = get_user_model().objects.get(email="siteeditor@example.com")
         self.login(user)
 
         # ensure the bulk_delete is not applicable to this user
@@ -109,7 +148,15 @@ class TestBulkMove(TestCase, WagtailTestUtils):
         self.assertFalse(can_bulk_delete)
 
         response = self.client.get(
-            reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'move', )) + f'?id={self.unpublished_page.id}'
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "move",
+                ),
+            )
+            + f"?id={self.unpublished_page.id}"
         )
 
         self.assertEqual(response.status_code, 200)
@@ -118,35 +165,53 @@ class TestBulkMove(TestCase, WagtailTestUtils):
         page = BusinessChild(title="Section no child", slug="section-no-child")
         self.root_page.add_child(instance=page)
 
-        response = self.client.post(self.url, {'chooser': page.id})
+        response = self.client.post(self.url, {"chooser": page.id})
 
         html = response.content.decode()
 
-        self.assertInHTML('<p>The following pages cannot be moved to {}</p>'.format(page.title), html)
+        self.assertInHTML(
+            "<p>The following pages cannot be moved to {}</p>".format(page.title), html
+        )
 
         for child_page in self.pages_to_be_moved:
-            self.assertInHTML('<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">{page_title}</a></li>'.format(
-                edit_page_url=reverse('wagtailadmin_pages:edit', args=[child_page.id]),
-                page_title=child_page.title
-            ), html)
+            self.assertInHTML(
+                '<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">{page_title}</a></li>'.format(
+                    edit_page_url=reverse(
+                        "wagtailadmin_pages:edit", args=[child_page.id]
+                    ),
+                    page_title=child_page.title,
+                ),
+                html,
+            )
 
     def test_bulk_move_slug_already_taken(self):
-        temp_page_1 = SimplePage(title="Hello world!", slug="hello-world-b", content="hello")
-        temp_page_2 = SimplePage(title="Hello world!", slug="hello-world-b-1", content="hello")
+        temp_page_1 = SimplePage(
+            title="Hello world!", slug="hello-world-b", content="hello"
+        )
+        temp_page_2 = SimplePage(
+            title="Hello world!", slug="hello-world-b-1", content="hello"
+        )
         self.section_b.add_child(instance=temp_page_1)
         self.section_b.add_child(instance=temp_page_2)
 
-        response = self.client.post(self.url, {'chooser': self.section_b.id})
+        response = self.client.post(self.url, {"chooser": self.section_b.id})
 
         html = response.content.decode()
 
-        self.assertInHTML('<p>The following pages cannot be moved due to duplicate slugs</p>', html)
+        self.assertInHTML(
+            "<p>The following pages cannot be moved due to duplicate slugs</p>", html
+        )
 
         for child_page in self.pages_to_be_moved:
-            self.assertInHTML('<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">{page_title}</a></li>'.format(
-                edit_page_url=reverse('wagtailadmin_pages:edit', args=[child_page.id]),
-                page_title=child_page.title
-            ), html)
+            self.assertInHTML(
+                '<li><a href="{edit_page_url}" target="_blank" rel="noopener noreferrer">{page_title}</a></li>'.format(
+                    edit_page_url=reverse(
+                        "wagtailadmin_pages:edit", args=[child_page.id]
+                    ),
+                    page_title=child_page.title,
+                ),
+                html,
+            )
 
     def test_bulk_move_triggers_signals(self):
         # Connect a mock signal handler to pre_page_move and post_page_move signals
@@ -158,52 +223,59 @@ class TestBulkMove(TestCase, WagtailTestUtils):
 
         # Post to view confirm move page
         try:
-            self.client.post(self.url, {'chooser': self.section_b.id})
+            self.client.post(self.url, {"chooser": self.section_b.id})
         finally:
             # Disconnect mock handler to prevent cross-test pollution
             pre_page_move.disconnect(pre_moved_handler)
             post_page_move.disconnect(post_moved_handler)
 
         # Check that the pre_page_move signals were fired
-        self.assertTrue(pre_moved_handler.mock_calls[0].called_with(
-            sender=self.test_page_b.specific_class,
-            instance=self.test_page_b,
-            parent_page_before=self.section_c,
-            parent_page_after=self.section_b,
-            url_path_before='/home/section-c/hello-world-b/',
-            url_path_after='/home/section-b/hello-world-b/',
-        ))
-        self.assertTrue(pre_moved_handler.mock_calls[1].called_with(
-            sender=self.test_page_b_1.specific_class,
-            instance=self.test_page_b_1,
-            parent_page_before=self.section_c,
-            parent_page_after=self.section_b,
-            url_path_before='/home/section-c/hello-world-b-1/',
-            url_path_after='/home/section-b/hello-world-b-1/',
-        ))
+        self.assertTrue(
+            pre_moved_handler.mock_calls[0].called_with(
+                sender=self.test_page_b.specific_class,
+                instance=self.test_page_b,
+                parent_page_before=self.section_c,
+                parent_page_after=self.section_b,
+                url_path_before="/home/section-c/hello-world-b/",
+                url_path_after="/home/section-b/hello-world-b/",
+            )
+        )
+        self.assertTrue(
+            pre_moved_handler.mock_calls[1].called_with(
+                sender=self.test_page_b_1.specific_class,
+                instance=self.test_page_b_1,
+                parent_page_before=self.section_c,
+                parent_page_after=self.section_b,
+                url_path_before="/home/section-c/hello-world-b-1/",
+                url_path_after="/home/section-b/hello-world-b-1/",
+            )
+        )
 
         # Check that the post_page_move signals were fired
-        self.assertTrue(post_moved_handler.mock_calls[0].called_with(
-            sender=self.test_page_b.specific_class,
-            instance=self.test_page_b,
-            parent_page_before=self.section_c,
-            parent_page_after=self.section_b,
-            url_path_before='/home/section-c/hello-world-b/',
-            url_path_after='/home/section-b/hello-world-b/',
-        ))
-        self.assertTrue(post_moved_handler.mock_calls[1].called_with(
-            sender=self.test_page_b_1.specific_class,
-            instance=self.test_page_b_1,
-            parent_page_before=self.section_c,
-            parent_page_after=self.section_b,
-            url_path_before='/home/section-c/hello-world-b-1/',
-            url_path_after='/home/section-b/hello-world-b-1/',
-        ))
+        self.assertTrue(
+            post_moved_handler.mock_calls[0].called_with(
+                sender=self.test_page_b.specific_class,
+                instance=self.test_page_b,
+                parent_page_before=self.section_c,
+                parent_page_after=self.section_b,
+                url_path_before="/home/section-c/hello-world-b/",
+                url_path_after="/home/section-b/hello-world-b/",
+            )
+        )
+        self.assertTrue(
+            post_moved_handler.mock_calls[1].called_with(
+                sender=self.test_page_b_1.specific_class,
+                instance=self.test_page_b_1,
+                parent_page_before=self.section_c,
+                parent_page_after=self.section_b,
+                url_path_before="/home/section-c/hello-world-b-1/",
+                url_path_after="/home/section-b/hello-world-b-1/",
+            )
+        )
 
     def test_before_bulk_move_hook(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'move')
+            self.assertEqual(action_type, "move")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
@@ -211,25 +283,23 @@ class TestBulkMove(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_bulk_action', hook_func):
-            response = self.client.post(self.url, {'chooser': self.section_b.id})
+        with self.register_hook("before_bulk_action", hook_func):
+            response = self.client.post(self.url, {"chooser": self.section_b.id})
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
 
         self.assertEqual(
-            Page.objects.get(id=self.test_page_b.id).get_parent().id,
-            self.section_c.id
+            Page.objects.get(id=self.test_page_b.id).get_parent().id, self.section_c.id
         )
         self.assertEqual(
             Page.objects.get(id=self.test_page_b_1.id).get_parent().id,
-            self.section_c.id
+            self.section_c.id,
         )
 
     def test_after_bulk_move_hook(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'move')
+            self.assertEqual(action_type, "move")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
@@ -237,18 +307,17 @@ class TestBulkMove(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_bulk_action', hook_func):
-            response = self.client.post(self.url, {'chooser': self.section_b.id})
+        with self.register_hook("after_bulk_action", hook_func):
+            response = self.client.post(self.url, {"chooser": self.section_b.id})
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
 
         # pages should be moved
         self.assertEqual(
-            Page.objects.get(id=self.test_page_b.id).get_parent().id,
-            self.section_b.id
+            Page.objects.get(id=self.test_page_b.id).get_parent().id, self.section_b.id
         )
         self.assertEqual(
             Page.objects.get(id=self.test_page_b_1.id).get_parent().id,
-            self.section_b.id
+            self.section_b.id,
         )

+ 107 - 32
wagtail/admin/tests/pages/test_bulk_actions/test_bulk_publish.py

@@ -19,7 +19,12 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
 
         # Add child pages
         self.child_pages = [
-            SimplePage(title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"Hello world {i}!", live=False)
+            SimplePage(
+                title=f"Hello world!-{i}",
+                slug=f"hello-world-{i}",
+                content=f"Hello world {i}!",
+                live=False,
+            )
             for i in range(1, 5)
         ]
         self.pages_to_be_published = self.child_pages[:3]
@@ -32,10 +37,20 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
             child_page.content = f"Hello updated world {i}!"
             child_page.save_revision()
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'publish', )) + '?'
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "publish",
+                ),
+            )
+            + "?"
+        )
         for child_page in self.pages_to_be_published:
-            self.url += f'id={child_page.id}&'
-        self.redirect_url = reverse('wagtailadmin_explore', args=(self.root_page.id, ))
+            self.url += f"id={child_page.id}&"
+        self.redirect_url = reverse("wagtailadmin_explore", args=(self.root_page.id,))
 
         self.user = self.login()
 
@@ -48,7 +63,9 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
 
         # # Check that the user received an publish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html"
+        )
 
         # Page titles shown on the confirmation page should use SimplePage's custom get_admin_display_title method
         self.assertContains(response, "Hello world!-1 (simple page)")
@@ -58,7 +75,16 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
         This tests that the publish view returns an error if the page id is invalid
         """
         # Request confirm publish page but with illegal page id
-        response = self.client.get(reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'publish', )))
+        response = self.client.get(
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "publish",
+                ),
+            )
+        )
 
         # Check that the user received a 404 response
         self.assertEqual(response.status_code, 404)
@@ -70,7 +96,9 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
@@ -82,10 +110,14 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
 
         html = response.content.decode()
 
-        self.assertInHTML("<p>You don't have permission to publish these pages</p>", html)
+        self.assertInHTML(
+            "<p>You don't have permission to publish these pages</p>", html
+        )
 
         for child_page in self.pages_to_be_published:
-            self.assertInHTML('<li>{page_title}</li>'.format(page_title=child_page.title), html)
+            self.assertInHTML(
+                "<li>{page_title}</li>".format(page_title=child_page.title), html
+            )
 
     def test_publish_view_post(self):
         """
@@ -116,14 +148,13 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
 
         for i, child_page in enumerate(self.pages_to_be_published):
             mock_call = mock_handler.mock_calls[i][2]
-            self.assertEqual(mock_call['sender'], child_page.specific_class)
-            self.assertEqual(mock_call['instance'], child_page)
-            self.assertIsInstance(mock_call['instance'], child_page.specific_class)
+            self.assertEqual(mock_call["sender"], child_page.specific_class)
+            self.assertEqual(mock_call["instance"], child_page)
+            self.assertIsInstance(mock_call["instance"], child_page.specific_class)
 
     def test_after_publish_page(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'publish')
+            self.assertEqual(action_type, "publish")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
@@ -131,7 +162,7 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_bulk_action', hook_func):
+        with self.register_hook("after_bulk_action", hook_func):
             response = self.client.post(self.url)
 
         self.assertEqual(response.status_code, 200)
@@ -142,9 +173,8 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
             self.assertEqual(child_page.status_string, _("live"))
 
     def test_before_publish_page(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'publish')
+            self.assertEqual(action_type, "publish")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
@@ -152,7 +182,7 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_bulk_action', hook_func):
+        with self.register_hook("before_bulk_action", hook_func):
             response = self.client.post(self.url)
 
         self.assertEqual(response.status_code, 200)
@@ -167,9 +197,14 @@ class TestBulkPublish(TestCase, WagtailTestUtils):
 
         # Check that the user received an publish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html"
+        )
         # Check the form does not contain the checkbox field include_descendants
-        self.assertNotContains(response, '<input id="id_include_descendants" name="include_descendants" type="checkbox">')
+        self.assertNotContains(
+            response,
+            '<input id="id_include_descendants" name="include_descendants" type="checkbox">',
+        )
 
 
 class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
@@ -178,7 +213,12 @@ class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
 
         # Add child pages
         self.child_pages = [
-            SimplePage(title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"Hello world {i}!", live=False)
+            SimplePage(
+                title=f"Hello world!-{i}",
+                slug=f"hello-world-{i}",
+                content=f"Hello world {i}!",
+                live=False,
+            )
             for i in range(1, 5)
         ]
         self.pages_to_be_published = self.child_pages[:3]
@@ -194,11 +234,27 @@ class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
         # map of the form { page: [child_pages] } to be added
         self.grandchildren_pages = {
             self.pages_to_be_published[0]: [
-                SimplePage(title="Hello world!-a", slug="hello-world-a", content="Hello world a!", live=False)],
+                SimplePage(
+                    title="Hello world!-a",
+                    slug="hello-world-a",
+                    content="Hello world a!",
+                    live=False,
+                )
+            ],
             self.pages_to_be_published[1]: [
-                SimplePage(title="Hello world!-b", slug="hello-world-b", content="Hello world b!", live=False),
-                SimplePage(title="Hello world!-c", slug="hello-world-c", content="Hello world c!", live=False)
-            ]
+                SimplePage(
+                    title="Hello world!-b",
+                    slug="hello-world-b",
+                    content="Hello world b!",
+                    live=False,
+                ),
+                SimplePage(
+                    title="Hello world!-c",
+                    slug="hello-world-c",
+                    content="Hello world c!",
+                    live=False,
+                ),
+            ],
         }
         for child_page, grandchild_pages in self.grandchildren_pages.items():
             for grandchild_page in grandchild_pages:
@@ -206,12 +262,24 @@ class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
 
         for child_page, grandchild_pages in self.grandchildren_pages.items():
             for grandchild_page in grandchild_pages:
-                grandchild_page.content = grandchild_page.content.replace("Hello world", "Hello grandchild")
+                grandchild_page.content = grandchild_page.content.replace(
+                    "Hello world", "Hello grandchild"
+                )
                 grandchild_page.save_revision()
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'publish', )) + '?'
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "publish",
+                ),
+            )
+            + "?"
+        )
         for child_page in self.pages_to_be_published:
-            self.url += f'&id={child_page.id}'
+            self.url += f"&id={child_page.id}"
 
         self.user = self.login()
 
@@ -224,16 +292,21 @@ class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
 
         # Check that the user received an publish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html"
+        )
         # Check the form contains the checkbox field include_descendants
-        self.assertContains(response, '<input type="checkbox" name="include_descendants" id="id_include_descendants">')
+        self.assertContains(
+            response,
+            '<input type="checkbox" name="include_descendants" id="id_include_descendants">',
+        )
 
     def test_publish_include_children_view_post(self):
         """
         This posts to the publish view and checks that the page and its descendants were published
         """
         # Post to the publish page
-        response = self.client.post(self.url, {'include_descendants': 'on'})
+        response = self.client.post(self.url, {"include_descendants": "on"})
 
         # Should be redirected to explorer page
         self.assertEqual(response.status_code, 302)
@@ -250,7 +323,9 @@ class TestBulkPublishIncludingDescendants(TestCase, WagtailTestUtils):
 
         for grandchild_pages in self.grandchildren_pages.values():
             for grandchild_page in grandchild_pages:
-                published_grandchild_page = SimplePage.objects.get(id=grandchild_page.id)
+                published_grandchild_page = SimplePage.objects.get(
+                    id=grandchild_page.id
+                )
                 self.assertTrue(published_grandchild_page.live)
                 self.assertIn("Hello grandchild", published_grandchild_page.content)
 

+ 89 - 31
wagtail/admin/tests/pages/test_bulk_actions/test_bulk_unpublish.py

@@ -18,7 +18,9 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
         # Create pages to unpublish
         self.root_page = Page.objects.get(id=2)
         self.child_pages = [
-            SimplePage(title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"hello-{i}")
+            SimplePage(
+                title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"hello-{i}"
+            )
             for i in range(1, 5)
         ]
         # first three child pages will be unpublished
@@ -27,10 +29,20 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
         for child_page in self.child_pages:
             self.root_page.add_child(instance=child_page)
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'unpublish', )) + '?'
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "unpublish",
+                ),
+            )
+            + "?"
+        )
         for child_page in self.pages_to_be_unpublished:
-            self.url += f'&id={child_page.id}'
-        self.redirect_url = reverse('wagtailadmin_explore', args=(self.root_page.id, ))
+            self.url += f"&id={child_page.id}"
+        self.redirect_url = reverse("wagtailadmin_explore", args=(self.root_page.id,))
 
         # Login
         self.user = self.login()
@@ -44,14 +56,25 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
 
         # Check that the user received an unpublish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html"
+        )
 
     def test_unpublish_view_invalid_page_id(self):
         """
         This tests that the unpublish view returns an error if the page id is invalid
         """
         # Request confirm unpublish page but with illegal page id
-        response = self.client.get(reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'unpublish', )))
+        response = self.client.get(
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "unpublish",
+                ),
+            )
+        )
 
         # Check that the user received a 404 response
         self.assertEqual(response.status_code, 404)
@@ -63,7 +86,9 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
@@ -75,10 +100,14 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
 
         html = response.content.decode()
 
-        self.assertInHTML("<p>You don't have permission to unpublish these pages</p>", html)
+        self.assertInHTML(
+            "<p>You don't have permission to unpublish these pages</p>", html
+        )
 
         for child_page in self.pages_to_be_unpublished:
-            self.assertInHTML('<li>{page_title}</li>'.format(page_title=child_page.title), html)
+            self.assertInHTML(
+                "<li>{page_title}</li>".format(page_title=child_page.title), html
+            )
 
     def test_unpublish_view_post(self):
         """
@@ -107,14 +136,13 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
 
         for i, child_page in enumerate(self.pages_to_be_unpublished):
             mock_call = mock_handler.mock_calls[i][2]
-            self.assertEqual(mock_call['sender'], child_page.specific_class)
-            self.assertEqual(mock_call['instance'], child_page)
-            self.assertIsInstance(mock_call['instance'], child_page.specific_class)
+            self.assertEqual(mock_call["sender"], child_page.specific_class)
+            self.assertEqual(mock_call["instance"], child_page)
+            self.assertIsInstance(mock_call["instance"], child_page.specific_class)
 
     def test_after_unpublish_page(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'unpublish')
+            self.assertEqual(action_type, "unpublish")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
@@ -122,7 +150,7 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_bulk_action', hook_func):
+        with self.register_hook("after_bulk_action", hook_func):
             response = self.client.post(self.url)
 
         self.assertEqual(response.status_code, 200)
@@ -133,9 +161,8 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
             self.assertEqual(child_page.status_string, _("draft"))
 
     def test_before_unpublish_page(self):
-
         def hook_func(request, action_type, pages, action_class_instance):
-            self.assertEqual(action_type, 'unpublish')
+            self.assertEqual(action_type, "unpublish")
             self.assertIsInstance(request, HttpRequest)
             self.assertIsInstance(action_class_instance, PageBulkAction)
             for i, page in enumerate(pages):
@@ -143,7 +170,7 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_bulk_action', hook_func):
+        with self.register_hook("before_bulk_action", hook_func):
             response = self.client.post(self.url)
 
         self.assertEqual(response.status_code, 200)
@@ -158,9 +185,15 @@ class TestBulkUnpublish(TestCase, WagtailTestUtils):
 
         # Check that the user received an unpublish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html"
+        )
         # Check the form does not contain the checkbox field include_descendants
-        self.assertContains(response, '<input type="checkbox" name="include_descendants" id="id_include_descendants">', count=0)
+        self.assertContains(
+            response,
+            '<input type="checkbox" name="include_descendants" id="id_include_descendants">',
+            count=0,
+        )
 
 
 class TestBulkUnpublishIncludingDescendants(TestCase, WagtailTestUtils):
@@ -169,7 +202,9 @@ class TestBulkUnpublishIncludingDescendants(TestCase, WagtailTestUtils):
         self.root_page = Page.objects.get(id=2)
 
         self.child_pages = [
-            SimplePage(title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"hello-{i}")
+            SimplePage(
+                title=f"Hello world!-{i}", slug=f"hello-world-{i}", content=f"hello-{i}"
+            )
             for i in range(1, 5)
         ]
         # first three child pages will be unpublished
@@ -180,21 +215,39 @@ class TestBulkUnpublishIncludingDescendants(TestCase, WagtailTestUtils):
 
         # map of the form { page: [child_pages] } to be added
         self.grandchildren_pages = {
-            self.pages_to_be_unpublished[0]: [SimplePage(title="Hello world!-a", slug="hello-world-a", content="hello-a")],
+            self.pages_to_be_unpublished[0]: [
+                SimplePage(
+                    title="Hello world!-a", slug="hello-world-a", content="hello-a"
+                )
+            ],
             self.pages_to_be_unpublished[1]: [
-                SimplePage(title="Hello world!-b", slug="hello-world-b", content="hello-b"),
-                SimplePage(title="Hello world!-c", slug="hello-world-c", content="hello-c")
-            ]
+                SimplePage(
+                    title="Hello world!-b", slug="hello-world-b", content="hello-b"
+                ),
+                SimplePage(
+                    title="Hello world!-c", slug="hello-world-c", content="hello-c"
+                ),
+            ],
         }
 
         for child_page, grandchild_pages in self.grandchildren_pages.items():
             for grandchild_page in grandchild_pages:
                 child_page.add_child(instance=grandchild_page)
 
-        self.url = reverse('wagtail_bulk_action', args=('wagtailcore', 'page', 'unpublish', )) + '?'
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    "wagtailcore",
+                    "page",
+                    "unpublish",
+                ),
+            )
+            + "?"
+        )
         for child_page in self.pages_to_be_unpublished:
-            self.url += f'&id={child_page.id}'
-        self.redirect_url = reverse('wagtailadmin_explore', args=(self.root_page.id, ))
+            self.url += f"&id={child_page.id}"
+        self.redirect_url = reverse("wagtailadmin_explore", args=(self.root_page.id,))
 
         self.user = self.login()
 
@@ -207,16 +260,21 @@ class TestBulkUnpublishIncludingDescendants(TestCase, WagtailTestUtils):
 
         # Check that the user received an unpublish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html"
+        )
         # Check the form contains the checkbox field include_descendants
-        self.assertContains(response, '<input type="checkbox" name="include_descendants" id="id_include_descendants">')
+        self.assertContains(
+            response,
+            '<input type="checkbox" name="include_descendants" id="id_include_descendants">',
+        )
 
     def test_unpublish_include_children_view_post(self):
         """
         This posts to the unpublish view and checks that the page and its descendants were unpublished
         """
         # Post to the unpublish page
-        response = self.client.post(self.url, {'include_descendants': 'on'})
+        response = self.client.post(self.url, {"include_descendants": "on"})
 
         # Should be redirected to explorer page
         self.assertEqual(response.status_code, 302)

+ 8 - 5
wagtail/admin/tests/pages/test_content_type_use_view.py

@@ -7,7 +7,7 @@ from wagtail.tests.utils import WagtailTestUtils
 
 
 class TestContentTypeUse(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
         self.user = self.login()
@@ -15,17 +15,20 @@ class TestContentTypeUse(TestCase, WagtailTestUtils):
 
     def test_content_type_use(self):
         # Get use of event page
-        request_url = reverse('wagtailadmin_pages:type_use', args=('tests', 'eventpage'))
+        request_url = reverse(
+            "wagtailadmin_pages:type_use", args=("tests", "eventpage")
+        )
         response = self.client.get(request_url)
 
         # Check response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/content_type_use.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/content_type_use.html")
         self.assertContains(response, "Christmas")
 
         # Links to 'delete' etc should include a 'next' URL parameter pointing back here
         delete_url = (
-            reverse('wagtailadmin_pages:delete', args=(self.christmas_page.id,))
-            + '?' + urlencode({'next': request_url})
+            reverse("wagtailadmin_pages:delete", args=(self.christmas_page.id,))
+            + "?"
+            + urlencode({"next": request_url})
         )
         self.assertContains(response, delete_url)

+ 33 - 11
wagtail/admin/tests/pages/test_convert_alias.py

@@ -15,41 +15,55 @@ class TestConvertAlias(TestCase, WagtailTestUtils):
         self.root_page = Page.objects.get(id=2)
 
         # Add child page
-        self.child_page = SimplePage(title="Hello world!", slug="hello-world", content="hello")
+        self.child_page = SimplePage(
+            title="Hello world!", slug="hello-world", content="hello"
+        )
         self.root_page.add_child(instance=self.child_page)
 
         # Add alias page
-        self.alias_page = self.child_page.create_alias(update_slug='alias-page')
+        self.alias_page = self.child_page.create_alias(update_slug="alias-page")
 
         # Login
         self.user = self.login()
 
     def test_convert_alias(self):
-        response = self.client.get(reverse('wagtailadmin_pages:convert_alias', args=[self.alias_page.id]))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:convert_alias", args=[self.alias_page.id])
+        )
         self.assertEqual(response.status_code, 200)
 
     def test_convert_alias_not_alias(self):
-        response = self.client.get(reverse('wagtailadmin_pages:convert_alias', args=[self.child_page.id]))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:convert_alias", args=[self.child_page.id])
+        )
         self.assertEqual(response.status_code, 404)
 
     def test_convert_alias_bad_permission(self):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
-        response = self.client.get(reverse('wagtailadmin_pages:convert_alias', args=[self.alias_page.id]))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:convert_alias", args=[self.alias_page.id])
+        )
 
         # Check that the user received a permission denied response
-        self.assertRedirects(response, '/admin/')
+        self.assertRedirects(response, "/admin/")
 
     def test_post_convert_alias(self):
-        response = self.client.post(reverse('wagtailadmin_pages:convert_alias', args=[self.alias_page.id]))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:convert_alias", args=[self.alias_page.id])
+        )
 
         # User should be redirected to the edit view of the converted page
-        self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.alias_page.id]))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_pages:edit", args=[self.alias_page.id])
+        )
 
         # Check the page was converted
         self.alias_page.refresh_from_db()
@@ -61,9 +75,17 @@ class TestConvertAlias(TestCase, WagtailTestUtils):
         self.assertEqual(self.alias_page.live_revision, revision)
 
         # Check audit log
-        log = PageLogEntry.objects.get(action='wagtail.convert_alias')
+        log = PageLogEntry.objects.get(action="wagtail.convert_alias")
         self.assertFalse(log.content_changed)
-        self.assertEqual(json.loads(log.data_json), {"page": {"id": self.alias_page.id, "title": self.alias_page.get_admin_display_title()}})
+        self.assertEqual(
+            json.loads(log.data_json),
+            {
+                "page": {
+                    "id": self.alias_page.id,
+                    "title": self.alias_page.get_admin_display_title(),
+                }
+            },
+        )
         self.assertEqual(log.page, self.alias_page.page_ptr)
         self.assertEqual(log.revision, revision)
         self.assertEqual(log.user, self.user)

+ 301 - 196
wagtail/admin/tests/pages/test_copy_page.py

@@ -9,46 +9,53 @@ from wagtail.tests.utils import WagtailTestUtils
 
 
 class TestPageCopy(TestCase, WagtailTestUtils):
-
     def setUp(self):
         # Find root page
         self.root_page = Page.objects.get(id=2)
 
         # Create a page
-        self.test_page = self.root_page.add_child(instance=SimplePage(
-            title="Hello world!",
-            slug='hello-world',
-            content="hello",
-            live=True,
-            has_unpublished_changes=False,
-        ))
+        self.test_page = self.root_page.add_child(
+            instance=SimplePage(
+                title="Hello world!",
+                slug="hello-world",
+                content="hello",
+                live=True,
+                has_unpublished_changes=False,
+            )
+        )
 
         # Create a couple of child pages
-        self.test_child_page = self.test_page.add_child(instance=SimplePage(
-            title="Child page",
-            slug='child-page',
-            content="hello",
-            live=True,
-            has_unpublished_changes=True,
-        ))
-
-        self.test_unpublished_child_page = self.test_page.add_child(instance=SimplePage(
-            title="Unpublished Child page",
-            slug='unpublished-child-page',
-            content="hello",
-            live=False,
-            has_unpublished_changes=True,
-        ))
+        self.test_child_page = self.test_page.add_child(
+            instance=SimplePage(
+                title="Child page",
+                slug="child-page",
+                content="hello",
+                live=True,
+                has_unpublished_changes=True,
+            )
+        )
+
+        self.test_unpublished_child_page = self.test_page.add_child(
+            instance=SimplePage(
+                title="Unpublished Child page",
+                slug="unpublished-child-page",
+                content="hello",
+                live=False,
+                has_unpublished_changes=True,
+            )
+        )
 
         # Login
         self.user = self.login()
 
     def test_page_copy(self):
-        response = self.client.get(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,))
+        )
 
         # Check response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/copy.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/copy.html")
 
         # Make sure all fields are in the form
         self.assertContains(response, "New title")
@@ -62,61 +69,71 @@ class TestPageCopy(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         # Get copy page
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world',
-            'new_parent_page': str(self.test_page.id),
-            'copy_subpages': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world",
+            "new_parent_page": str(self.test_page.id),
+            "copy_subpages": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # A user with no page permissions at all should be redirected to the admin home
-        self.assertRedirects(response, reverse('wagtailadmin_home'))
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
 
         # A user with page permissions, but not add permission at the destination,
         # should receive a form validation error
-        publishers = Group.objects.create(name='Publishers')
+        publishers = Group.objects.create(name="Publishers")
         GroupPagePermission.objects.create(
-            group=publishers, page=self.root_page, permission_type='publish'
+            group=publishers, page=self.root_page, permission_type="publish"
         )
         self.user.groups.add(publishers)
         self.user.save()
 
         # Get copy page
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world',
-            'new_parent_page': str(self.test_page.id),
-            'copy_subpages': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world",
+            "new_parent_page": str(self.test_page.id),
+            "copy_subpages": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
-        form = response.context['form']
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
+        form = response.context["form"]
         self.assertFalse(form.is_valid())
-        self.assertIn('new_parent_page', form.errors)
+        self.assertIn("new_parent_page", form.errors)
 
     def test_page_copy_post(self):
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world-2',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': False,
-            'publish_copies': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world-2",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": False,
+            "publish_copies": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Get copy
-        page_copy = self.root_page.get_children().filter(slug='hello-world-2').first()
+        page_copy = self.root_page.get_children().filter(slug="hello-world-2").first()
 
         # Check that the copy exists
         self.assertIsNotNone(page_copy)
@@ -132,24 +149,30 @@ class TestPageCopy(TestCase, WagtailTestUtils):
         self.assertEqual(page_copy.get_children().count(), 0)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
     def test_page_copy_post_copy_subpages(self):
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world-2',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': True,
-            'publish_copies': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world-2",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": True,
+            "publish_copies": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Get copy
-        page_copy = self.root_page.get_children().filter(slug='hello-world-2').first()
+        page_copy = self.root_page.get_children().filter(slug="hello-world-2").first()
 
         # Check that the copy exists
         self.assertIsNotNone(page_copy)
@@ -166,35 +189,43 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
         # Check the the child pages
         # Neither of them should be live
-        child_copy = page_copy.get_children().filter(slug='child-page').first()
+        child_copy = page_copy.get_children().filter(slug="child-page").first()
         self.assertIsNotNone(child_copy)
         self.assertFalse(child_copy.live)
         self.assertTrue(child_copy.has_unpublished_changes)
 
-        unpublished_child_copy = page_copy.get_children().filter(slug='unpublished-child-page').first()
+        unpublished_child_copy = (
+            page_copy.get_children().filter(slug="unpublished-child-page").first()
+        )
         self.assertIsNotNone(unpublished_child_copy)
         self.assertFalse(unpublished_child_copy.live)
         self.assertTrue(unpublished_child_copy.has_unpublished_changes)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
     def test_page_copy_post_copy_subpages_publish_copies(self):
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world-2',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': True,
-            'publish_copies': True,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world-2",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": True,
+            "publish_copies": True,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Get copy
-        page_copy = self.root_page.get_children().filter(slug='hello-world-2').first()
+        page_copy = self.root_page.get_children().filter(slug="hello-world-2").first()
 
         # Check that the copy exists
         self.assertIsNotNone(page_copy)
@@ -211,51 +242,66 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
         # Check the the child pages
         # The child_copy should be live but the unpublished_child_copy shouldn't
-        child_copy = page_copy.get_children().filter(slug='child-page').first()
+        child_copy = page_copy.get_children().filter(slug="child-page").first()
         self.assertIsNotNone(child_copy)
         self.assertTrue(child_copy.live)
         self.assertTrue(child_copy.has_unpublished_changes)
 
-        unpublished_child_copy = page_copy.get_children().filter(slug='unpublished-child-page').first()
+        unpublished_child_copy = (
+            page_copy.get_children().filter(slug="unpublished-child-page").first()
+        )
         self.assertIsNotNone(unpublished_child_copy)
         self.assertFalse(unpublished_child_copy.live)
         self.assertTrue(unpublished_child_copy.has_unpublished_changes)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
     def test_page_copy_post_new_parent(self):
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world-2',
-            'new_parent_page': str(self.test_child_page.id),
-            'copy_subpages': False,
-            'publish_copies': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world-2",
+            "new_parent_page": str(self.test_child_page.id),
+            "copy_subpages": False,
+            "publish_copies": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the new parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.test_child_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.test_child_page.id,))
+        )
 
         # Check that the page was copied to the correct place
-        self.assertTrue(Page.objects.filter(slug='hello-world-2').first().get_parent(), self.test_child_page)
+        self.assertTrue(
+            Page.objects.filter(slug="hello-world-2").first().get_parent(),
+            self.test_child_page,
+        )
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
     def test_page_copy_post_existing_slug_within_same_parent_page(self):
         # This tests the existing slug checking on page copy when not changing the parent page
 
         # Attempt to copy the page but forget to change the slug
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Should not be redirected (as the save should fail)
         self.assertEqual(response.status_code, 200)
@@ -263,28 +309,33 @@ class TestPageCopy(TestCase, WagtailTestUtils):
         # Check that a form error was raised
         self.assertFormError(
             response,
-            'form',
-            'new_slug',
-            "This slug is already in use within the context of its parent page \"Welcome to your new Wagtail site!\""
+            "form",
+            "new_slug",
+            'This slug is already in use within the context of its parent page "Welcome to your new Wagtail site!"',
         )
 
     def test_page_copy_post_and_subpages_to_same_tree_branch(self):
         # This tests that a page cannot be copied into itself when copying subpages
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world',
-            'new_parent_page': str(self.test_child_page.id),
-            'copy_subpages': True,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world",
+            "new_parent_page": str(self.test_child_page.id),
+            "copy_subpages": True,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id,)), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Should not be redirected (as the save should fail)
         self.assertEqual(response.status_code, 200)
 
         # Check that a form error was raised
         self.assertFormError(
-            response, 'form', 'new_parent_page', "You cannot copy a page into itself when copying subpages"
+            response,
+            "form",
+            "new_parent_page",
+            "You cannot copy a page into itself when copying subpages",
         )
 
     def test_page_copy_post_existing_slug_to_another_parent_page(self):
@@ -292,55 +343,70 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
         # Attempt to copy the page and changed the parent page
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world',
-            'new_parent_page': str(self.test_child_page.id),
-            'copy_subpages': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world",
+            "new_parent_page": str(self.test_child_page.id),
+            "copy_subpages": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.test_child_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.test_child_page.id,))
+        )
 
     def test_page_copy_post_invalid_slug(self):
         # Attempt to copy the page but set an invalid slug string
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello world!',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': False,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello world!",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Should not be redirected (as the save should fail)
         self.assertEqual(response.status_code, 200)
 
         # Check that a form error was raised
         self.assertFormError(
-            response, 'form', 'new_slug', "Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens."
+            response,
+            "form",
+            "new_slug",
+            "Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens.",
         )
 
     def test_page_copy_post_valid_unicode_slug(self):
         post_data = {
-            'new_title': "Hello wɜːld",
-            'new_slug': 'hello-wɜːld',
-            'new_parent_page': str(self.test_page.id),
-            'copy_subpages': False,
-            'alias': False,
+            "new_title": "Hello wɜːld",
+            "new_slug": "hello-wɜːld",
+            "new_parent_page": str(self.test_page.id),
+            "copy_subpages": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.test_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.test_page.id,))
+        )
 
         # Get copy
-        page_copy = self.test_page.get_children().filter(slug=post_data['new_slug']).first()
+        page_copy = (
+            self.test_page.get_children().filter(slug=post_data["new_slug"]).first()
+        )
 
         # Check that the copy exists with the good slug
         self.assertIsNotNone(page_copy)
-        self.assertEqual(page_copy.slug, post_data['new_slug'])
+        self.assertEqual(page_copy.slug, post_data["new_slug"])
 
     def test_page_copy_no_publish_permission(self):
         # Turn user into an editor who can add pages but not publish them
@@ -351,11 +417,13 @@ class TestPageCopy(TestCase, WagtailTestUtils):
         self.user.save()
 
         # Get copy page
-        response = self.client.get(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,))
+        )
 
         # The user should have access to the copy page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/copy.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/copy.html")
 
         # Make sure the "publish copies" field is hidden
         self.assertNotContains(response, "Publish copies")
@@ -372,20 +440,24 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
         # Post
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world-2',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': True,
-            'publish_copies': True,
-            'alias': False,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world-2",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": True,
+            "publish_copies": True,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Get copy
-        page_copy = self.root_page.get_children().filter(slug='hello-world-2').first()
+        page_copy = self.root_page.get_children().filter(slug="hello-world-2").first()
 
         # Check that the copy exists
         self.assertIsNotNone(page_copy)
@@ -401,16 +473,20 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
         # Check the the child pages
         # Neither of them should be live
-        child_copy = page_copy.get_children().filter(slug='child-page').first()
+        child_copy = page_copy.get_children().filter(slug="child-page").first()
         self.assertIsNotNone(child_copy)
         self.assertFalse(child_copy.live)
 
-        unpublished_child_copy = page_copy.get_children().filter(slug='unpublished-child-page').first()
+        unpublished_child_copy = (
+            page_copy.get_children().filter(slug="unpublished-child-page").first()
+        )
         self.assertIsNotNone(unpublished_child_copy)
         self.assertFalse(unpublished_child_copy.live)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
     def test_before_copy_page_hook(self):
         def hook_func(request, page):
@@ -419,8 +495,10 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_copy_page', hook_func):
-            response = self.client.get(reverse('wagtailadmin_pages:copy', args=(self.test_page.id,)))
+        with self.register_hook("before_copy_page", hook_func):
+            response = self.client.get(
+                reverse("wagtailadmin_pages:copy", args=(self.test_page.id,))
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
@@ -432,16 +510,18 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_copy_page', hook_func):
+        with self.register_hook("before_copy_page", hook_func):
             post_data = {
-                'new_title': "Hello world 2",
-                'new_slug': 'hello-world-2',
-                'new_parent_page': str(self.root_page.id),
-                'copy_subpages': False,
-                'publish_copies': False,
-                'alias': False,
+                "new_title": "Hello world 2",
+                "new_slug": "hello-world-2",
+                "new_parent_page": str(self.root_page.id),
+                "copy_subpages": False,
+                "publish_copies": False,
+                "alias": False,
             }
-            response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id,)), post_data)
+            response = self.client.post(
+                reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
@@ -457,16 +537,18 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_copy_page', hook_func):
+        with self.register_hook("after_copy_page", hook_func):
             post_data = {
-                'new_title': "Hello world 2",
-                'new_slug': 'hello-world-2',
-                'new_parent_page': str(self.root_page.id),
-                'copy_subpages': False,
-                'publish_copies': False,
-                'alias': False,
+                "new_title": "Hello world 2",
+                "new_slug": "hello-world-2",
+                "new_parent_page": str(self.root_page.id),
+                "copy_subpages": False,
+                "publish_copies": False,
+                "alias": False,
             }
-            response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id,)), post_data)
+            response = self.client.post(
+                reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
@@ -476,20 +558,24 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
     def test_page_copy_alias_post(self):
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world-2',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': False,
-            'publish_copies': False,
-            'alias': True,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world-2",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": False,
+            "publish_copies": False,
+            "alias": True,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Get copy
-        page_copy = self.root_page.get_children().get(slug='hello-world-2')
+        page_copy = self.root_page.get_children().get(slug="hello-world-2")
 
         # Check the copy is an alias of the original
         self.assertEqual(page_copy.alias_of, self.test_page.page_ptr)
@@ -506,24 +592,30 @@ class TestPageCopy(TestCase, WagtailTestUtils):
         self.assertEqual(page_copy.get_children().count(), 0)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
     def test_page_copy_alias_post_copy_subpages(self):
         post_data = {
-            'new_title': "Hello world 2",
-            'new_slug': 'hello-world-2',
-            'new_parent_page': str(self.root_page.id),
-            'copy_subpages': True,
-            'publish_copies': False,
-            'alias': True,
+            "new_title": "Hello world 2",
+            "new_slug": "hello-world-2",
+            "new_parent_page": str(self.root_page.id),
+            "copy_subpages": True,
+            "publish_copies": False,
+            "alias": True,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=(self.test_page.id, )), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=(self.test_page.id,)), post_data
+        )
 
         # Check that the user was redirected to the parents explore page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Get copy
-        page_copy = self.root_page.get_children().get(slug='hello-world-2')
+        page_copy = self.root_page.get_children().get(slug="hello-world-2")
 
         # Check the copy is an alias of the original
         self.assertEqual(page_copy.alias_of, self.test_page.page_ptr)
@@ -541,49 +633,62 @@ class TestPageCopy(TestCase, WagtailTestUtils):
 
         # Check the the child pages
         # Neither of them should be live
-        child_copy = page_copy.get_children().filter(slug='child-page').first()
+        child_copy = page_copy.get_children().filter(slug="child-page").first()
         self.assertIsNotNone(child_copy)
         self.assertEqual(child_copy.alias_of, self.test_child_page.page_ptr)
         self.assertTrue(child_copy.live)
         self.assertFalse(child_copy.has_unpublished_changes)
 
-        unpublished_child_copy = page_copy.get_children().filter(slug='unpublished-child-page').first()
+        unpublished_child_copy = (
+            page_copy.get_children().filter(slug="unpublished-child-page").first()
+        )
         self.assertIsNotNone(unpublished_child_copy)
-        self.assertEqual(unpublished_child_copy.alias_of, self.test_unpublished_child_page.page_ptr)
+        self.assertEqual(
+            unpublished_child_copy.alias_of, self.test_unpublished_child_page.page_ptr
+        )
         self.assertFalse(unpublished_child_copy.live)
         self.assertTrue(unpublished_child_copy.has_unpublished_changes)
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
     def test_page_copy_alias_post_without_source_publish_permission(self):
         # Check for issue #7293 - If the user has permission to publish at a destination, but not the source.
         # Wagtail would crash on attempt to copy
 
         # Create a new section
-        self.destination_page = self.root_page.add_child(instance=SimplePage(
-            title="Destination page",
-            slug='destination-page',
-            content="hello",
-            live=True,
-            has_unpublished_changes=False,
-        ))
+        self.destination_page = self.root_page.add_child(
+            instance=SimplePage(
+                title="Destination page",
+                slug="destination-page",
+                content="hello",
+                live=True,
+                has_unpublished_changes=False,
+            )
+        )
 
         # Make user a moderator and make it so they can only publish at the destination page
         self.user.is_superuser = False
         self.user.groups.add(Group.objects.get(name="Moderators"))
         self.user.save()
-        GroupPagePermission.objects.filter(permission_type='publish').update(page=self.destination_page)
+        GroupPagePermission.objects.filter(permission_type="publish").update(
+            page=self.destination_page
+        )
 
         post_data = {
-            'new_title': self.test_child_page.title,
-            'new_slug': self.test_child_page.slug,
-            'new_parent_page': str(self.destination_page.id),
-            'copy_subpages': False,
-            'publish_copies': False,
-            'alias': False,
+            "new_title": self.test_child_page.title,
+            "new_slug": self.test_child_page.slug,
+            "new_parent_page": str(self.destination_page.id),
+            "copy_subpages": False,
+            "publish_copies": False,
+            "alias": False,
         }
-        response = self.client.post(reverse('wagtailadmin_pages:copy', args=[self.test_child_page.id]), post_data)
+        response = self.client.post(
+            reverse("wagtailadmin_pages:copy", args=[self.test_child_page.id]),
+            post_data,
+        )
 
         # We only need to check that it didn't crash
         self.assertEqual(response.status_code, 302)

Plik diff jest za duży
+ 427 - 187
wagtail/admin/tests/pages/test_create_page.py


+ 32 - 24
wagtail/admin/tests/pages/test_dashboard.py

@@ -23,82 +23,90 @@ class TestRecentEditsPanel(TestCase, WagtailTestUtils):
         child_page.save_revision().publish()
         self.child_page = SimplePage.objects.get(id=child_page.id)
 
-        self.create_superuser(username='alice', password='password')
-        self.create_superuser(username='bob', password='password')
+        self.create_superuser(username="alice", password="password")
+        self.create_superuser(username="bob", password="password")
 
     def change_something(self, title):
-        post_data = {'title': title, 'content': "Some content", 'slug': 'hello-world'}
-        response = self.client.post(reverse('wagtailadmin_pages:edit', args=(self.child_page.id, )), post_data)
+        post_data = {"title": title, "content": "Some content", "slug": "hello-world"}
+        response = self.client.post(
+            reverse("wagtailadmin_pages:edit", args=(self.child_page.id,)), post_data
+        )
 
         # Should be redirected to edit page
-        self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=(self.child_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
+        )
 
         # The page should have "has_unpublished_changes" flag set
         child_page_new = SimplePage.objects.get(id=self.child_page.id)
         self.assertTrue(child_page_new.has_unpublished_changes)
 
     def go_to_dashboard_response(self):
-        response = self.client.get(reverse('wagtailadmin_home'))
+        response = self.client.get(reverse("wagtailadmin_home"))
         self.assertEqual(response.status_code, 200)
         return response
 
     def test_your_recent_edits(self):
         # Login as Bob
-        self.login(username='bob', password='password')
+        self.login(username="bob", password="password")
 
         # Bob hasn't edited anything yet
-        response = self.client.get(reverse('wagtailadmin_home'))
-        self.assertNotIn('Your most recent edits', response.content.decode('utf-8'))
+        response = self.client.get(reverse("wagtailadmin_home"))
+        self.assertNotIn("Your most recent edits", response.content.decode("utf-8"))
 
         # Login as Alice
         self.client.logout()
-        self.login(username='alice', password='password')
+        self.login(username="alice", password="password")
 
         # Alice changes something
         self.change_something("Alice's edit")
 
         # Edit should show up on dashboard
         response = self.go_to_dashboard_response()
-        self.assertIn('Your most recent edits', response.content.decode('utf-8'))
+        self.assertIn("Your most recent edits", response.content.decode("utf-8"))
 
         # Bob changes something
-        self.login(username='bob', password='password')
+        self.login(username="bob", password="password")
         self.change_something("Bob's edit")
 
         # Edit shows up on Bobs dashboard
         response = self.go_to_dashboard_response()
-        self.assertIn('Your most recent edits', response.content.decode('utf-8'))
+        self.assertIn("Your most recent edits", response.content.decode("utf-8"))
 
         # Login as Alice again
         self.client.logout()
-        self.login(username='alice', password='password')
+        self.login(username="alice", password="password")
 
         # Alice's dashboard should still list that first edit
         response = self.go_to_dashboard_response()
-        self.assertIn('Your most recent edits', response.content.decode('utf-8'))
+        self.assertIn("Your most recent edits", response.content.decode("utf-8"))
 
     def test_panel(self):
-        """Test if the panel actually returns expected pages """
-        self.login(username='bob', password='password')
+        """Test if the panel actually returns expected pages"""
+        self.login(username="bob", password="password")
         # change a page
         self.change_something("Bob's edit")
         # set a user to 'mock' a request
-        self.client.user = get_user_model().objects.get(email='bob@example.com')
+        self.client.user = get_user_model().objects.get(email="bob@example.com")
         # get the panel to get the last edits
         panel = RecentEditsPanel()
-        ctx = panel.get_context_data({'request': self.client})
+        ctx = panel.get_context_data({"request": self.client})
 
         # check if the revision is the revision of edited Page
-        self.assertEqual(ctx['last_edits'][0][0].page, Page.objects.get(pk=self.child_page.id))
+        self.assertEqual(
+            ctx["last_edits"][0][0].page, Page.objects.get(pk=self.child_page.id)
+        )
         # check if the page in this list is the specific page of this revision
-        self.assertEqual(ctx['last_edits'][0][1], Page.objects.get(pk=self.child_page.id).specific)
+        self.assertEqual(
+            ctx["last_edits"][0][1], Page.objects.get(pk=self.child_page.id).specific
+        )
 
 
 class TestRecentEditsQueryCount(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.bob = self.create_superuser(username='bob', password='password')
+        self.bob = self.create_superuser(username="bob", password="password")
 
         # make a bunch of page edits (all to EventPages, so that calls to specific() don't add
         # an unpredictable number of queries)
@@ -113,7 +121,7 @@ class TestRecentEditsQueryCount(TestCase, WagtailTestUtils):
             # Instantiating/getting context of RecentEditsPanel should not generate N+1 queries -
             # i.e. any number less than 6 would be reasonable here
             panel = RecentEditsPanel()
-            parent_context = {'request': self.client}
+            parent_context = {"request": self.client}
             panel.get_context_data(parent_context)
 
         # check that the panel is still actually returning results

+ 86 - 32
wagtail/admin/tests/pages/test_delete_page.py

@@ -18,26 +18,32 @@ class TestPageDelete(TestCase, WagtailTestUtils):
         self.root_page = Page.objects.get(id=2)
 
         # Add child page
-        self.child_page = SimplePage(title="Hello world!", slug="hello-world", content="hello")
+        self.child_page = SimplePage(
+            title="Hello world!", slug="hello-world", content="hello"
+        )
         self.root_page.add_child(instance=self.child_page)
 
         # Add a page with child pages of its own
-        self.child_index = StandardIndex(title="Hello index", slug='hello-index')
+        self.child_index = StandardIndex(title="Hello index", slug="hello-index")
         self.root_page.add_child(instance=self.child_index)
-        self.grandchild_page = StandardChild(title="Hello Kitty", slug='hello-kitty')
+        self.grandchild_page = StandardChild(title="Hello Kitty", slug="hello-kitty")
         self.child_index.add_child(instance=self.grandchild_page)
 
         # Login
         self.user = self.login()
 
     def test_page_delete(self):
-        response = self.client.get(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
         # deletion should not actually happen on GET
         self.assertTrue(SimplePage.objects.filter(id=self.child_page.id).exists())
 
     def test_page_delete_specific_admin_title(self):
-        response = self.client.get(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
 
         # The admin_display_title specific to ChildPage is shown on the delete confirmation page.
@@ -47,12 +53,16 @@ class TestPageDelete(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         # Get delete page
-        response = self.client.get(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+        )
 
         # Check that the user received a 302 redirect response
         self.assertEqual(response.status_code, 302)
@@ -66,24 +76,35 @@ class TestPageDelete(TestCase, WagtailTestUtils):
         page_unpublished.connect(mock_handler)
 
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+        )
 
         # Should be redirected to explorer page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
         # Check that the page is gone
-        self.assertEqual(Page.objects.filter(path__startswith=self.root_page.path, slug='hello-world').count(), 0)
+        self.assertEqual(
+            Page.objects.filter(
+                path__startswith=self.root_page.path, slug="hello-world"
+            ).count(),
+            0,
+        )
 
         # Check that the page_unpublished signal was fired
         self.assertEqual(mock_handler.call_count, 1)
         mock_call = mock_handler.mock_calls[0][2]
 
-        self.assertEqual(mock_call['sender'], self.child_page.specific_class)
-        self.assertEqual(mock_call['instance'], self.child_page)
-        self.assertIsInstance(mock_call['instance'], self.child_page.specific_class)
+        self.assertEqual(mock_call["sender"], self.child_page.specific_class)
+        self.assertEqual(mock_call["instance"], self.child_page)
+        self.assertIsInstance(mock_call["instance"], self.child_page.specific_class)
 
     def test_page_delete_notlive_post(self):
         # Same as above, but this makes sure the page_unpublished signal is not fired
@@ -98,16 +119,27 @@ class TestPageDelete(TestCase, WagtailTestUtils):
         page_unpublished.connect(mock_handler)
 
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+        )
 
         # Should be redirected to explorer page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
         # Check that the page is gone
-        self.assertEqual(Page.objects.filter(path__startswith=self.root_page.path, slug='hello-world').count(), 0)
+        self.assertEqual(
+            Page.objects.filter(
+                path__startswith=self.root_page.path, slug="hello-world"
+            ).count(),
+            0,
+        )
 
         # Check that the page_unpublished signal was not fired
         self.assertEqual(mock_handler.call_count, 0)
@@ -132,31 +164,47 @@ class TestPageDelete(TestCase, WagtailTestUtils):
         post_delete.connect(post_delete_handler)
 
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:delete', args=(self.child_index.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:delete", args=(self.child_index.id,))
+        )
 
         # Should be redirected to explorer page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # treebeard should report no consistency problems with the tree
-        self.assertFalse(any(Page.find_problems()), 'treebeard found consistency problems')
+        self.assertFalse(
+            any(Page.find_problems()), "treebeard found consistency problems"
+        )
 
         # Check that the page is gone
         self.assertFalse(StandardIndex.objects.filter(id=self.child_index.id).exists())
         self.assertFalse(Page.objects.filter(id=self.child_index.id).exists())
 
         # Check that the subpage is also gone
-        self.assertFalse(StandardChild.objects.filter(id=self.grandchild_page.id).exists())
+        self.assertFalse(
+            StandardChild.objects.filter(id=self.grandchild_page.id).exists()
+        )
         self.assertFalse(Page.objects.filter(id=self.grandchild_page.id).exists())
 
         # Check that the signals were fired for both pages
         self.assertIn((StandardIndex, self.child_index.id), unpublish_signals_received)
-        self.assertIn((StandardChild, self.grandchild_page.id), unpublish_signals_received)
+        self.assertIn(
+            (StandardChild, self.grandchild_page.id), unpublish_signals_received
+        )
 
         self.assertIn((StandardIndex, self.child_index.id), pre_delete_signals_received)
-        self.assertIn((StandardChild, self.grandchild_page.id), pre_delete_signals_received)
+        self.assertIn(
+            (StandardChild, self.grandchild_page.id), pre_delete_signals_received
+        )
 
-        self.assertIn((StandardIndex, self.child_index.id), post_delete_signals_received)
-        self.assertIn((StandardChild, self.grandchild_page.id), post_delete_signals_received)
+        self.assertIn(
+            (StandardIndex, self.child_index.id), post_delete_signals_received
+        )
+        self.assertIn(
+            (StandardChild, self.grandchild_page.id), post_delete_signals_received
+        )
 
     def test_before_delete_page_hook(self):
         def hook_func(request, page):
@@ -165,8 +213,10 @@ class TestPageDelete(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_delete_page', hook_func):
-            response = self.client.get(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        with self.register_hook("before_delete_page", hook_func):
+            response = self.client.get(
+                reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
@@ -178,8 +228,10 @@ class TestPageDelete(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_delete_page', hook_func):
-            response = self.client.post(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        with self.register_hook("before_delete_page", hook_func):
+            response = self.client.post(
+                reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
@@ -194,8 +246,10 @@ class TestPageDelete(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_delete_page', hook_func):
-            response = self.client.post(reverse('wagtailadmin_pages:delete', args=(self.child_page.id, )))
+        with self.register_hook("after_delete_page", hook_func):
+            response = self.client.post(
+                reverse("wagtailadmin_pages:delete", args=(self.child_page.id,))
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")

Plik diff jest za duży
+ 371 - 228
wagtail/admin/tests/pages/test_edit_page.py


+ 275 - 170
wagtail/admin/tests/pages/test_explorer_view.py

@@ -28,7 +28,7 @@ class TestPageExplorer(TestCase, WagtailTestUtils):
         self.old_page = StandardIndex(
             title="Old page",
             slug="old-page",
-            latest_revision_created_at=local_datetime(2010, 1, 1)
+            latest_revision_created_at=local_datetime(2010, 1, 1),
         )
         self.root_page.add_child(instance=self.old_page)
 
@@ -36,7 +36,7 @@ class TestPageExplorer(TestCase, WagtailTestUtils):
             title="New page",
             slug="new-page",
             content="hello",
-            latest_revision_created_at=local_datetime(2016, 1, 1)
+            latest_revision_created_at=local_datetime(2016, 1, 1),
         )
         self.root_page.add_child(instance=self.new_page)
 
@@ -44,181 +44,215 @@ class TestPageExplorer(TestCase, WagtailTestUtils):
         self.user = self.login()
 
     def test_explore(self):
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertEqual(self.root_page, response.context['parent_page'])
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertEqual(self.root_page, response.context["parent_page"])
 
         # child pages should be most recent first
         # (with null latest_revision_created_at at the end)
-        page_ids = [page.id for page in response.context['pages']]
-        self.assertEqual(page_ids, [self.new_page.id, self.old_page.id, self.child_page.id])
+        page_ids = [page.id for page in response.context["pages"]]
+        self.assertEqual(
+            page_ids, [self.new_page.id, self.old_page.id, self.child_page.id]
+        )
 
     def test_explore_root(self):
-        response = self.client.get(reverse('wagtailadmin_explore_root'))
+        response = self.client.get(reverse("wagtailadmin_explore_root"))
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertEqual(Page.objects.get(id=1), response.context['parent_page'])
-        self.assertTrue(response.context['pages'].paginator.object_list.filter(id=self.root_page.id).exists())
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertEqual(Page.objects.get(id=1), response.context["parent_page"])
+        self.assertTrue(
+            response.context["pages"]
+            .paginator.object_list.filter(id=self.root_page.id)
+            .exists()
+        )
 
     def test_explore_root_shows_icon(self):
-        response = self.client.get(reverse('wagtailadmin_explore_root'))
+        response = self.client.get(reverse("wagtailadmin_explore_root"))
         self.assertEqual(response.status_code, 200)
 
         # Administrator (or user with add_site permission) should see the
         # sites link with the icon-site icon
         self.assertContains(
             response,
-            ("""<a href="/admin/sites/" class="icon icon-site" """
-             """title="Sites menu"></a>""")
+            (
+                """<a href="/admin/sites/" class="icon icon-site" """
+                """title="Sites menu"></a>"""
+            ),
         )
 
     def test_ordering(self):
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=(self.root_page.id, )),
-            {'ordering': 'title'}
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
+            {"ordering": "title"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertEqual(response.context['ordering'], 'title')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertEqual(response.context["ordering"], "title")
 
         # child pages should be ordered by title
-        page_ids = [page.id for page in response.context['pages']]
-        self.assertEqual(page_ids, [self.child_page.id, self.new_page.id, self.old_page.id])
+        page_ids = [page.id for page in response.context["pages"]]
+        self.assertEqual(
+            page_ids, [self.child_page.id, self.new_page.id, self.old_page.id]
+        )
 
     def test_reverse_ordering(self):
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=(self.root_page.id, )),
-            {'ordering': '-title'}
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
+            {"ordering": "-title"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertEqual(response.context['ordering'], '-title')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertEqual(response.context["ordering"], "-title")
 
         # child pages should be ordered by title
-        page_ids = [page.id for page in response.context['pages']]
-        self.assertEqual(page_ids, [self.old_page.id, self.new_page.id, self.child_page.id])
+        page_ids = [page.id for page in response.context["pages"]]
+        self.assertEqual(
+            page_ids, [self.old_page.id, self.new_page.id, self.child_page.id]
+        )
 
     def test_ordering_by_last_revision_forward(self):
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=(self.root_page.id, )),
-            {'ordering': 'latest_revision_created_at'}
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
+            {"ordering": "latest_revision_created_at"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertEqual(response.context['ordering'], 'latest_revision_created_at')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertEqual(response.context["ordering"], "latest_revision_created_at")
 
         # child pages should be oldest revision first
         # (with null latest_revision_created_at at the start)
-        page_ids = [page.id for page in response.context['pages']]
-        self.assertEqual(page_ids, [self.child_page.id, self.old_page.id, self.new_page.id])
+        page_ids = [page.id for page in response.context["pages"]]
+        self.assertEqual(
+            page_ids, [self.child_page.id, self.old_page.id, self.new_page.id]
+        )
 
     def test_invalid_ordering(self):
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=(self.root_page.id, )),
-            {'ordering': 'invalid_order'}
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
+            {"ordering": "invalid_order"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertEqual(response.context['ordering'], '-latest_revision_created_at')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertEqual(response.context["ordering"], "-latest_revision_created_at")
 
     def test_reordering(self):
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=(self.root_page.id, )),
-            {'ordering': 'ord'}
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
+            {"ordering": "ord"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertEqual(response.context['ordering'], 'ord')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertEqual(response.context["ordering"], "ord")
 
         # child pages should be ordered by native tree order (i.e. by creation time)
-        page_ids = [page.id for page in response.context['pages']]
-        self.assertEqual(page_ids, [self.child_page.id, self.old_page.id, self.new_page.id])
+        page_ids = [page.id for page in response.context["pages"]]
+        self.assertEqual(
+            page_ids, [self.child_page.id, self.old_page.id, self.new_page.id]
+        )
 
         # Pages must not be paginated
-        self.assertNotIsInstance(response.context['pages'], paginator.Page)
+        self.assertNotIsInstance(response.context["pages"], paginator.Page)
 
     def test_construct_explorer_page_queryset_hook(self):
         # testapp implements a construct_explorer_page_queryset hook
         # that only returns pages with a slug starting with 'hello'
         # when the 'polite_pages_only' URL parameter is set
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=(self.root_page.id, )),
-            {'polite_pages_only': 'yes_please'}
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
+            {"polite_pages_only": "yes_please"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        page_ids = [page.id for page in response.context['pages']]
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        page_ids = [page.id for page in response.context["pages"]]
         self.assertEqual(page_ids, [self.child_page.id])
 
     def test_construct_explorer_page_queryset_hook_with_ordering(self):
         def set_custom_ordering(parent_page, pages, request):
-            return pages.order_by('-title')
+            return pages.order_by("-title")
 
-        with hooks.register_temporarily('construct_explorer_page_queryset', set_custom_ordering):
+        with hooks.register_temporarily(
+            "construct_explorer_page_queryset", set_custom_ordering
+        ):
             response = self.client.get(
-                reverse('wagtailadmin_explore', args=(self.root_page.id, ))
+                reverse("wagtailadmin_explore", args=(self.root_page.id,))
             )
 
         # child pages should be ordered by according to the hook preference
-        page_ids = [page.id for page in response.context['pages']]
-        self.assertEqual(page_ids, [self.old_page.id, self.new_page.id, self.child_page.id])
+        page_ids = [page.id for page in response.context["pages"]]
+        self.assertEqual(
+            page_ids, [self.old_page.id, self.new_page.id, self.child_page.id]
+        )
 
     def test_construct_page_listing_buttons_hook(self):
         # testapp implements a construct_page_listing_buttons hook
         # that add's an dummy button with the label 'Dummy Button' which points
         # to '/dummy-button'
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=(self.root_page.id, )),
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
         )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
-        self.assertContains(response, 'Dummy Button')
-        self.assertContains(response, '/dummy-button')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
+        self.assertContains(response, "Dummy Button")
+        self.assertContains(response, "/dummy-button")
 
     def make_pages(self):
         for i in range(150):
-            self.root_page.add_child(instance=SimplePage(
-                title="Page " + str(i),
-                slug="page-" + str(i),
-                content="hello",
-            ))
+            self.root_page.add_child(
+                instance=SimplePage(
+                    title="Page " + str(i),
+                    slug="page-" + str(i),
+                    content="hello",
+                )
+            )
 
     def test_pagination(self):
         self.make_pages()
 
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )), {'p': 2})
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)), {"p": 2}
+        )
 
         # Check response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
 
         # Check that we got the correct page
-        self.assertEqual(response.context['pages'].number, 2)
+        self.assertEqual(response.context["pages"].number, 2)
 
     def test_pagination_invalid(self):
         self.make_pages()
 
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )), {'p': 'Hello World!'})
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)),
+            {"p": "Hello World!"},
+        )
 
         # Check response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
 
         # Check that we got page one
-        self.assertEqual(response.context['pages'].number, 1)
+        self.assertEqual(response.context["pages"].number, 1)
 
     def test_pagination_out_of_range(self):
         self.make_pages()
 
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )), {'p': 99999})
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,)), {"p": 99999}
+        )
 
         # Check response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
 
         # Check that we got the last page
-        self.assertEqual(response.context['pages'].number, response.context['pages'].paginator.num_pages)
+        self.assertEqual(
+            response.context["pages"].number,
+            response.context["pages"].paginator.num_pages,
+        )
 
     @override_settings(USE_L10N=True, USE_THOUSAND_SEPARATOR=True)
     def test_no_thousand_separators_in_bulk_action_checkbox(self):
@@ -226,13 +260,17 @@ class TestPageExplorer(TestCase, WagtailTestUtils):
         Test that the USE_THOUSAND_SEPARATOR setting does mess up object IDs in
         bulk actions checkboxes
         """
-        self.root_page.add_child(instance=SimplePage(
-            pk=1000,
-            title="Page 1000",
-            slug="page-1000",
-            content="hello",
-        ))
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.root_page.add_child(
+            instance=SimplePage(
+                pk=1000,
+                title="Page 1000",
+                slug="page-1000",
+                content="hello",
+            )
+        )
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
         expected = 'data-object-id="1000"'
         self.assertContains(response, expected)
 
@@ -242,87 +280,117 @@ class TestPageExplorer(TestCase, WagtailTestUtils):
         # of the class
         self.new_event = SingleEventPage(
             title="New event",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
-            latest_revision_created_at=local_datetime(2016, 1, 1)
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
+            latest_revision_created_at=local_datetime(2016, 1, 1),
         )
         self.root_page.add_child(instance=self.new_event)
 
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
 
-        self.assertContains(response, '/new-event/pointless-suffix/')
+        self.assertContains(response, "/new-event/pointless-suffix/")
 
     def make_event_pages(self, count):
         for i in range(count):
-            self.root_page.add_child(instance=SingleEventPage(
-                title="New event " + str(i),
-                location='the moon', audience='public',
-                cost='free', date_from='2001-01-01',
-                latest_revision_created_at=local_datetime(2016, 1, 1)
-            ))
+            self.root_page.add_child(
+                instance=SingleEventPage(
+                    title="New event " + str(i),
+                    location="the moon",
+                    audience="public",
+                    cost="free",
+                    date_from="2001-01-01",
+                    latest_revision_created_at=local_datetime(2016, 1, 1),
+                )
+            )
 
     def test_exploring_uses_specific_page_with_custom_display_title(self):
         # SingleEventPage has a custom get_admin_display_title method; explorer should
         # show the custom title rather than the basic database one
         self.make_event_pages(count=1)
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )))
-        self.assertContains(response, 'New event 0 (single event)')
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
+        self.assertContains(response, "New event 0 (single event)")
 
-        new_event = SingleEventPage.objects.latest('pk')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(new_event.id, )))
-        self.assertContains(response, 'New event 0 (single event)')
+        new_event = SingleEventPage.objects.latest("pk")
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(new_event.id,))
+        )
+        self.assertContains(response, "New event 0 (single event)")
 
     def test_parent_page_is_specific(self):
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.child_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
 
-        self.assertIsInstance(response.context['parent_page'], SimplePage)
+        self.assertIsInstance(response.context["parent_page"], SimplePage)
 
     def test_explorer_no_perms(self):
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
-        admin = reverse('wagtailadmin_home')
+        admin = reverse("wagtailadmin_home")
         self.assertRedirects(
-            self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, ))),
-            admin)
+            self.client.get(reverse("wagtailadmin_explore", args=(self.root_page.id,))),
+            admin,
+        )
         self.assertRedirects(
-            self.client.get(reverse('wagtailadmin_explore_root')), admin)
+            self.client.get(reverse("wagtailadmin_explore_root")), admin
+        )
 
     def test_explore_with_missing_page_model(self):
         # Create a ContentType that doesn't correspond to a real model
-        missing_page_content_type = ContentType.objects.create(app_label='tests', model='missingpage')
+        missing_page_content_type = ContentType.objects.create(
+            app_label="tests", model="missingpage"
+        )
         # Turn /home/old-page/ into this content type
-        Page.objects.filter(id=self.old_page.id).update(content_type=missing_page_content_type)
+        Page.objects.filter(id=self.old_page.id).update(
+            content_type=missing_page_content_type
+        )
 
         # try to browse the the listing that contains the missing model
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
 
         # try to browse into the page itself
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.old_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.old_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/index.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/index.html")
 
 
 class TestBreadcrumb(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def test_breadcrumb_uses_specific_titles(self):
         self.user = self.login()
 
         # get the explorer view for a subpage of a SimplePage
-        page = Page.objects.get(url_path='/home/secret-plans/steal-underpants/')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(page.id, )))
+        page = Page.objects.get(url_path="/home/secret-plans/steal-underpants/")
+        response = self.client.get(reverse("wagtailadmin_explore", args=(page.id,)))
 
         # The breadcrumb should pick up SimplePage's overridden get_admin_display_title method
-        expected_url = reverse('wagtailadmin_explore', args=(Page.objects.get(url_path='/home/secret-plans/').id, ))
-        expected = """
+        expected_url = reverse(
+            "wagtailadmin_explore",
+            args=(Page.objects.get(url_path="/home/secret-plans/").id,),
+        )
+        expected = (
+            """
             <li class="breadcrumb-item">
                 <a class="breadcrumb-link" href="%s"><span class="title">Secret plans (simple page)</span>
                     <svg class="icon icon-arrow-right arrow_right_icon" aria-hidden="true" focusable="false">
@@ -330,12 +398,14 @@ class TestBreadcrumb(TestCase, WagtailTestUtils):
                     </svg>
                 </a>
             </li>
-        """ % expected_url
+        """
+            % expected_url
+        )
         self.assertContains(response, expected, html=True)
 
 
 class TestPageExplorerSignposting(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
         # Find root page
@@ -356,8 +426,8 @@ class TestPageExplorerSignposting(TestCase, WagtailTestUtils):
     # warning messages should include advice re configuring sites
 
     def test_admin_at_root(self):
-        self.login(username='superuser', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore_root'))
+        self.login(username="superuser", password="password")
+        response = self.client.get(reverse("wagtailadmin_explore_root"))
         self.assertEqual(response.status_code, 200)
         # Administrator (or user with add_site permission) should get the full message
         # about configuring sites
@@ -366,13 +436,17 @@ class TestPageExplorerSignposting(TestCase, WagtailTestUtils):
             (
                 "The root level is where you can add new sites to your Wagtail installation. "
                 "Pages created here will not be accessible at any URL until they are associated with a site."
-            )
+            ),
+        )
+        self.assertContains(
+            response, """<a href="/admin/sites/">Configure a site now.</a>"""
         )
-        self.assertContains(response, """<a href="/admin/sites/">Configure a site now.</a>""")
 
     def test_admin_at_non_site_page(self):
-        self.login(username='superuser', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.no_site_page.id, )))
+        self.login(username="superuser", password="password")
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.no_site_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
         # Administrator (or user with add_site permission) should get a warning about
         # unroutable pages, and be directed to the site config area
@@ -381,13 +455,17 @@ class TestPageExplorerSignposting(TestCase, WagtailTestUtils):
             (
                 "There is no site set up for this location. "
                 "Pages created here will not be accessible at any URL until a site is associated with this location."
-            )
+            ),
+        )
+        self.assertContains(
+            response, """<a href="/admin/sites/">Configure a site now.</a>"""
         )
-        self.assertContains(response, """<a href="/admin/sites/">Configure a site now.</a>""")
 
     def test_admin_at_site_page(self):
-        self.login(username='superuser', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.site_page.id, )))
+        self.login(username="superuser", password="password")
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.site_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
         # There should be no warning message here
         self.assertNotContains(response, "Pages created here will not be accessible")
@@ -400,27 +478,31 @@ class TestPageExplorerSignposting(TestCase, WagtailTestUtils):
         # logic allows them to explore root
         GroupPagePermission.objects.create(
             group=Group.objects.get(name="Site-wide editors"),
-            page=self.no_site_page, permission_type='add'
+            page=self.no_site_page,
+            permission_type="add",
         )
-        self.login(username='siteeditor', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore_root'))
+        self.login(username="siteeditor", password="password")
+        response = self.client.get(reverse("wagtailadmin_explore_root"))
 
         self.assertEqual(response.status_code, 200)
         # Non-admin should get a simple "create pages as children of the homepage" prompt
         self.assertContains(
             response,
             "Pages created here will not be accessible at any URL. "
-            "To add pages to an existing site, create them as children of the homepage."
+            "To add pages to an existing site, create them as children of the homepage.",
         )
 
     def test_nonadmin_at_non_site_page(self):
         # Assign siteeditor permission over no_site_page
         GroupPagePermission.objects.create(
             group=Group.objects.get(name="Site-wide editors"),
-            page=self.no_site_page, permission_type='add'
+            page=self.no_site_page,
+            permission_type="add",
+        )
+        self.login(username="siteeditor", password="password")
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.no_site_page.id,))
         )
-        self.login(username='siteeditor', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.no_site_page.id, )))
 
         self.assertEqual(response.status_code, 200)
         # Non-admin should get a warning about unroutable pages
@@ -429,12 +511,14 @@ class TestPageExplorerSignposting(TestCase, WagtailTestUtils):
             (
                 "There is no site record for this location. "
                 "Pages created here will not be accessible at any URL."
-            )
+            ),
         )
 
     def test_nonadmin_at_site_page(self):
-        self.login(username='siteeditor', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.site_page.id, )))
+        self.login(username="siteeditor", password="password")
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.site_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
         # There should be no warning message here
         self.assertNotContains(response, "Pages created here will not be accessible")
@@ -444,36 +528,42 @@ class TestPageExplorerSignposting(TestCase, WagtailTestUtils):
 
     def test_bad_permissions_at_root(self):
         # 'siteeditor' does not have permission to explore the root
-        self.login(username='siteeditor', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore_root'))
+        self.login(username="siteeditor", password="password")
+        response = self.client.get(reverse("wagtailadmin_explore_root"))
 
         # Users without permission to explore here should be redirected to their explorable root.
         self.assertEqual(
-            (response.status_code, response['Location']),
-            (302, reverse('wagtailadmin_explore', args=(self.site_page.pk, )))
+            (response.status_code, response["Location"]),
+            (302, reverse("wagtailadmin_explore", args=(self.site_page.pk,))),
         )
 
     def test_bad_permissions_at_non_site_page(self):
         # 'siteeditor' does not have permission to explore no_site_page
-        self.login(username='siteeditor', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.no_site_page.id, )))
+        self.login(username="siteeditor", password="password")
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.no_site_page.id,))
+        )
 
         # Users without permission to explore here should be redirected to their explorable root.
         self.assertEqual(
-            (response.status_code, response['Location']),
-            (302, reverse('wagtailadmin_explore', args=(self.site_page.pk, )))
+            (response.status_code, response["Location"]),
+            (302, reverse("wagtailadmin_explore", args=(self.site_page.pk,))),
         )
 
     def test_bad_permissions_at_site_page(self):
         # Adjust siteeditor's permission so that they have permission over no_site_page
         # instead of site_page
-        Group.objects.get(name="Site-wide editors").page_permissions.update(page_id=self.no_site_page.id)
-        self.login(username='siteeditor', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=(self.site_page.id, )))
+        Group.objects.get(name="Site-wide editors").page_permissions.update(
+            page_id=self.no_site_page.id
+        )
+        self.login(username="siteeditor", password="password")
+        response = self.client.get(
+            reverse("wagtailadmin_explore", args=(self.site_page.id,))
+        )
         # Users without permission to explore here should be redirected to their explorable root.
         self.assertEqual(
-            (response.status_code, response['Location']),
-            (302, reverse('wagtailadmin_explore', args=(self.no_site_page.pk, )))
+            (response.status_code, response["Location"]),
+            (302, reverse("wagtailadmin_explore", args=(self.no_site_page.pk,))),
         )
 
 
@@ -508,27 +598,27 @@ class TestExplorablePageVisibility(TestCase, WagtailTestUtils):
     User "superman" is an admin.
     """
 
-    fixtures = ['test_explorable_pages.json']
+    fixtures = ["test_explorable_pages.json"]
 
     # Integration tests adapted from @coredumperror
 
     def test_admin_can_explore_every_page(self):
-        self.login(username='superman', password='password')
+        self.login(username="superman", password="password")
         for page in Page.objects.all():
-            response = self.client.get(reverse('wagtailadmin_explore', args=[page.pk]))
+            response = self.client.get(reverse("wagtailadmin_explore", args=[page.pk]))
             self.assertEqual(response.status_code, 200)
 
     def test_admin_sees_root_page_as_explorer_root(self):
-        self.login(username='superman', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore_root'))
+        self.login(username="superman", password="password")
+        response = self.client.get(reverse("wagtailadmin_explore_root"))
         self.assertEqual(response.status_code, 200)
         # Administrator should see the full list of children of the Root page.
         self.assertContains(response, "Welcome to testserver!")
         self.assertContains(response, "Welcome to example.com!")
 
     def test_admin_sees_breadcrumbs_up_to_root_page(self):
-        self.login(username='superman', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=[6]))
+        self.login(username="superman", password="password")
+        response = self.client.get(reverse("wagtailadmin_explore", args=[6]))
         self.assertEqual(response.status_code, 200)
         expected = """
             <li class="home breadcrumb-item">
@@ -568,8 +658,8 @@ class TestExplorablePageVisibility(TestCase, WagtailTestUtils):
         self.assertContains(response, expected, html=True)
 
     def test_nonadmin_sees_breadcrumbs_up_to_cca(self):
-        self.login(username='josh', password='password')
-        response = self.client.get(reverse('wagtailadmin_explore', args=[6]))
+        self.login(username="josh", password="password")
+        response = self.client.get(reverse("wagtailadmin_explore", args=[6]))
         self.assertEqual(response.status_code, 200)
         # While at "Page 1", Josh should see the breadcrumbs leading only as far back as the example.com homepage,
         # since it's his Closest Common Ancestor.
@@ -602,48 +692,63 @@ class TestExplorablePageVisibility(TestCase, WagtailTestUtils):
         self.assertNotContains(response, "Welcome to example.com!")
 
     def test_admin_home_page_changes_with_permissions(self):
-        self.login(username='bob', password='password')
-        response = self.client.get(reverse('wagtailadmin_home'))
+        self.login(username="bob", password="password")
+        response = self.client.get(reverse("wagtailadmin_home"))
         self.assertEqual(response.status_code, 200)
         # Bob should only see the welcome for example.com, not testserver
         self.assertContains(response, "Welcome to the example.com Wagtail CMS")
         self.assertNotContains(response, "testserver")
 
     def test_breadcrumb_with_no_user_permissions(self):
-        self.login(username='mary', password='password')
-        response = self.client.get(reverse('wagtailadmin_home'))
+        self.login(username="mary", password="password")
+        response = self.client.get(reverse("wagtailadmin_home"))
         self.assertEqual(response.status_code, 200)
         # Since Mary has no page permissions, she should not see the breadcrumb
-        self.assertNotContains(response, """<li class="home breadcrumb-item"><a class="breadcrumb-link" href="/admin/pages/4/" class="icon icon-home text-replace">Home</a></li>""")
+        self.assertNotContains(
+            response,
+            """<li class="home breadcrumb-item"><a class="breadcrumb-link" href="/admin/pages/4/" class="icon icon-home text-replace">Home</a></li>""",
+        )
 
 
 @override_settings(WAGTAIL_I18N_ENABLED=True)
 class TestLocaleSelector(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.events_page = Page.objects.get(url_path='/home/events/')
-        self.fr_locale = Locale.objects.create(language_code='fr')
-        self.translated_events_page = self.events_page.copy_for_translation(self.fr_locale, copy_parents=True)
+        self.events_page = Page.objects.get(url_path="/home/events/")
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.translated_events_page = self.events_page.copy_for_translation(
+            self.fr_locale, copy_parents=True
+        )
         self.user = self.login()
 
     def test_locale_selector(self):
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=[self.events_page.id])
+            reverse("wagtailadmin_explore", args=[self.events_page.id])
         )
 
         self.assertContains(response, '<li class="header-meta--locale">')
 
-        add_translation_url = reverse('wagtailadmin_explore', args=[self.translated_events_page.id])
-        self.assertContains(response, f'<a href="{add_translation_url}" aria-label="French" class="u-link is-live">')
+        add_translation_url = reverse(
+            "wagtailadmin_explore", args=[self.translated_events_page.id]
+        )
+        self.assertContains(
+            response,
+            f'<a href="{add_translation_url}" aria-label="French" class="u-link is-live">',
+        )
 
     @override_settings(WAGTAIL_I18N_ENABLED=False)
     def test_locale_selector_not_present_when_i18n_disabled(self):
         response = self.client.get(
-            reverse('wagtailadmin_explore', args=[self.events_page.id])
+            reverse("wagtailadmin_explore", args=[self.events_page.id])
         )
 
         self.assertNotContains(response, '<li class="header-meta--locale">')
 
-        add_translation_url = reverse('wagtailadmin_explore', args=[self.translated_events_page.id])
-        self.assertNotContains(response, f'<a href="{add_translation_url}" aria-label="French" class="u-link is-live">')
+        add_translation_url = reverse(
+            "wagtailadmin_explore", args=[self.translated_events_page.id]
+        )
+        self.assertNotContains(
+            response,
+            f'<a href="{add_translation_url}" aria-label="French" class="u-link is-live">',
+        )

+ 94 - 47
wagtail/admin/tests/pages/test_moderation.py

@@ -1,5 +1,4 @@
 import logging
-
 from itertools import chain
 from unittest import mock
 
@@ -20,9 +19,9 @@ from wagtail.users.models import UserProfile
 class TestApproveRejectModeration(TestCase, WagtailTestUtils):
     def setUp(self):
         self.submitter = self.create_superuser(
-            username='submitter',
-            email='submitter@email.com',
-            password='password',
+            username="submitter",
+            email="submitter@email.com",
+            password="password",
         )
 
         self.user = self.login()
@@ -31,7 +30,7 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         root_page = Page.objects.get(id=2)
         self.page = SimplePage(
             title="Hello world!",
-            slug='hello-world',
+            slug="hello-world",
             content="hello",
             live=False,
             has_unpublished_changes=True,
@@ -50,10 +49,12 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         page_published.connect(mock_handler)
 
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:approve_moderation', args=(self.revision.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:approve_moderation", args=(self.revision.id,))
+        )
 
         # Check that the user was redirected to the dashboard
-        self.assertRedirects(response, reverse('wagtailadmin_home'))
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
 
         page = Page.objects.get(id=self.page.id)
         # Page must be live
@@ -61,25 +62,27 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         # Page should now have no unpublished changes
         self.assertFalse(
             page.has_unpublished_changes,
-            "Approving moderation failed to set has_unpublished_changes=False"
+            "Approving moderation failed to set has_unpublished_changes=False",
         )
 
         # Check that the page_published signal was fired
         self.assertEqual(mock_handler.call_count, 1)
         mock_call = mock_handler.mock_calls[0][2]
 
-        self.assertEqual(mock_call['sender'], self.page.specific_class)
-        self.assertEqual(mock_call['instance'], self.page)
-        self.assertIsInstance(mock_call['instance'], self.page.specific_class)
+        self.assertEqual(mock_call["sender"], self.page.specific_class)
+        self.assertEqual(mock_call["instance"], self.page)
+        self.assertIsInstance(mock_call["instance"], self.page.specific_class)
 
     def test_approve_moderation_when_later_revision_exists(self):
         self.page.title = "Goodbye world!"
         self.page.save_revision(user=self.submitter, submitted_for_moderation=False)
 
-        response = self.client.post(reverse('wagtailadmin_pages:approve_moderation', args=(self.revision.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:approve_moderation", args=(self.revision.id,))
+        )
 
         # Check that the user was redirected to the dashboard
-        self.assertRedirects(response, reverse('wagtailadmin_home'))
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
 
         page = Page.objects.get(id=self.page.id)
         # Page must be live
@@ -89,7 +92,7 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         # Page should still have unpublished changes
         self.assertTrue(
             page.has_unpublished_changes,
-            "has_unpublished_changes incorrectly cleared on approve_moderation when a later revision exists"
+            "has_unpublished_changes incorrectly cleared on approve_moderation when a later revision exists",
         )
 
     def test_approve_moderation_view_bad_revision_id(self):
@@ -97,7 +100,9 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         This tests that the approve moderation view handles invalid revision ids correctly
         """
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:approve_moderation', args=(12345, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:approve_moderation", args=(12345,))
+        )
 
         # Check that the user received a 404 response
         self.assertEqual(response.status_code, 404)
@@ -109,12 +114,16 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:approve_moderation', args=(self.revision.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:approve_moderation", args=(self.revision.id,))
+        )
 
         # Check that the user received a 302 redirected response
         self.assertEqual(response.status_code, 302)
@@ -124,23 +133,29 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         This posts to the reject moderation view and checks that the page was rejected
         """
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:reject_moderation', args=(self.revision.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:reject_moderation", args=(self.revision.id,))
+        )
 
         # Check that the user was redirected to the dashboard
-        self.assertRedirects(response, reverse('wagtailadmin_home'))
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
 
         # Page must not be live
         self.assertFalse(Page.objects.get(id=self.page.id).live)
 
         # Revision must no longer be submitted for moderation
-        self.assertFalse(PageRevision.objects.get(id=self.revision.id).submitted_for_moderation)
+        self.assertFalse(
+            PageRevision.objects.get(id=self.revision.id).submitted_for_moderation
+        )
 
     def test_reject_moderation_view_bad_revision_id(self):
         """
         This tests that the reject moderation view handles invalid revision ids correctly
         """
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:reject_moderation', args=(12345, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:reject_moderation", args=(12345,))
+        )
 
         # Check that the user received a 404 response
         self.assertEqual(response.status_code, 404)
@@ -152,22 +167,30 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         # Post
-        response = self.client.post(reverse('wagtailadmin_pages:reject_moderation', args=(self.revision.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:reject_moderation", args=(self.revision.id,))
+        )
 
         # Check that the user received a 302 redirected response
         self.assertEqual(response.status_code, 302)
 
     def test_preview_for_moderation(self):
-        response = self.client.get(reverse('wagtailadmin_pages:preview_for_moderation', args=(self.revision.id, )))
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:preview_for_moderation", args=(self.revision.id,)
+            )
+        )
 
         # Check response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'tests/simple_page.html')
+        self.assertTemplateUsed(response, "tests/simple_page.html")
         self.assertContains(response, "Hello world!")
 
 
@@ -180,11 +203,17 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
         self.user = self.login()
 
         # Create two moderator users for testing 'submitted' email
-        self.moderator = self.create_superuser('moderator', 'moderator@email.com', 'password')
-        self.moderator2 = self.create_superuser('moderator2', 'moderator2@email.com', 'password')
+        self.moderator = self.create_superuser(
+            "moderator", "moderator@email.com", "password"
+        )
+        self.moderator2 = self.create_superuser(
+            "moderator2", "moderator2@email.com", "password"
+        )
 
         # Create a submitter for testing 'rejected' and 'approved' emails
-        self.submitter = self.create_user('submitter', 'submitter@email.com', 'password')
+        self.submitter = self.create_user(
+            "submitter", "submitter@email.com", "password"
+        )
 
         # User profiles for moderator2 and the submitter
         self.moderator2_profile = UserProfile.get_for_user(self.moderator2)
@@ -193,7 +222,7 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
         # Create a page and submit it for moderation
         self.child_page = SimplePage(
             title="Hello world!",
-            slug='hello-world',
+            slug="hello-world",
             content="hello",
             live=False,
         )
@@ -201,27 +230,36 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
 
         # POST data to edit the page
         self.post_data = {
-            'title': "I've been edited!",
-            'content': "Some content",
-            'slug': 'hello-world',
-            'action-submit': "Submit",
+            "title": "I've been edited!",
+            "content": "Some content",
+            "slug": "hello-world",
+            "action-submit": "Submit",
         }
 
     def submit(self):
-        return self.client.post(reverse('wagtailadmin_pages:edit', args=(self.child_page.id, )), self.post_data)
+        return self.client.post(
+            reverse("wagtailadmin_pages:edit", args=(self.child_page.id,)),
+            self.post_data,
+        )
 
     def silent_submit(self):
         """
         Sets up the child_page as needing moderation, without making a request
         """
-        self.child_page.save_revision(user=self.submitter, submitted_for_moderation=True)
+        self.child_page.save_revision(
+            user=self.submitter, submitted_for_moderation=True
+        )
         self.revision = self.child_page.get_latest_revision()
 
     def approve(self):
-        return self.client.post(reverse('wagtailadmin_pages:approve_moderation', args=(self.revision.id, )))
+        return self.client.post(
+            reverse("wagtailadmin_pages:approve_moderation", args=(self.revision.id,))
+        )
 
     def reject(self):
-        return self.client.post(reverse('wagtailadmin_pages:reject_moderation', args=(self.revision.id, )))
+        return self.client.post(
+            reverse("wagtailadmin_pages:reject_moderation", args=(self.revision.id,))
+        )
 
     def test_vanilla_profile(self):
         # Check that the vanilla profile has rejected notifications on
@@ -238,8 +276,10 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
 
         # Submitter must receive an approved email
         self.assertEqual(len(mail.outbox), 1)
-        self.assertEqual(mail.outbox[0].to, ['submitter@email.com'])
-        self.assertEqual(mail.outbox[0].subject, 'The page "Hello world!" has been approved')
+        self.assertEqual(mail.outbox[0].to, ["submitter@email.com"])
+        self.assertEqual(
+            mail.outbox[0].subject, 'The page "Hello world!" has been approved'
+        )
 
     def test_approved_notifications_preferences_respected(self):
         # Submitter doesn't want 'approved' emails
@@ -262,8 +302,10 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
 
         # Submitter must receive a rejected email
         self.assertEqual(len(mail.outbox), 1)
-        self.assertEqual(mail.outbox[0].to, ['submitter@email.com'])
-        self.assertEqual(mail.outbox[0].subject, 'The page "Hello world!" has been rejected')
+        self.assertEqual(mail.outbox[0].to, ["submitter@email.com"])
+        self.assertEqual(
+            mail.outbox[0].subject, 'The page "Hello world!" has been rejected'
+        )
 
     def test_rejected_notification_preferences_respected(self):
         # Submitter doesn't want 'rejected' emails
@@ -281,7 +323,7 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
     @override_settings(WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS=False)
     def test_disable_superuser_notification(self):
         # Add one of the superusers to the moderator group
-        self.moderator.groups.add(Group.objects.get(name='Moderators'))
+        self.moderator.groups.add(Group.objects.get(name="Moderators"))
 
         response = self.submit()
 
@@ -296,7 +338,9 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
         self.assertIn(self.moderator.email, email_to)
         self.assertNotIn(self.moderator2.email, email_to)
 
-    @mock.patch.object(EmailMultiAlternatives, 'send', side_effect=IOError('Server down'))
+    @mock.patch.object(
+        EmailMultiAlternatives, "send", side_effect=IOError("Server down")
+    )
     def test_email_send_error(self, mock_fn):
         logging.disable(logging.CRITICAL)
         # Approve
@@ -306,10 +350,10 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
 
         # An email that fails to send should return a message rather than crash the page
         self.assertEqual(response.status_code, 302)
-        response = self.client.get(reverse('wagtailadmin_home'))
+        response = self.client.get(reverse("wagtailadmin_home"))
 
         # There should be one "approved" message and one "failed to send notifications"
-        messages = list(response.context['messages'])
+        messages = list(response.context["messages"])
         self.assertEqual(len(messages), 2)
         self.assertEqual(messages[0].level, message_constants.SUCCESS)
         self.assertEqual(messages[1].level, message_constants.ERROR)
@@ -319,5 +363,8 @@ class TestNotificationPreferences(TestCase, WagtailTestUtils):
         self.submit()
 
         msg_headers = set(mail.outbox[0].message().items())
-        headers = {('Auto-Submitted', 'auto-generated')}
-        self.assertTrue(headers.issubset(msg_headers), msg='Message is missing the Auto-Submitted header.',)
+        headers = {("Auto-Submitted", "auto-generated")}
+        self.assertTrue(
+            headers.issubset(msg_headers),
+            msg="Message is missing the Auto-Submitted header.",
+        )

+ 95 - 47
wagtail/admin/tests/pages/test_move_page.py

@@ -15,32 +15,44 @@ from wagtail.tests.utils import WagtailTestUtils
 
 
 class TestPageMove(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
         # Find root page
         self.root_page = Page.objects.get(id=2)
 
         # Create three sections
-        self.section_a = SimplePage(title="Section A", slug="section-a", content="hello")
+        self.section_a = SimplePage(
+            title="Section A", slug="section-a", content="hello"
+        )
         self.root_page.add_child(instance=self.section_a)
 
-        self.section_b = SimplePage(title="Section B", slug="section-b", content="hello")
+        self.section_b = SimplePage(
+            title="Section B", slug="section-b", content="hello"
+        )
         self.root_page.add_child(instance=self.section_b)
 
-        self.section_c = SimplePage(title="Section C", slug="section-c", content="hello")
+        self.section_c = SimplePage(
+            title="Section C", slug="section-c", content="hello"
+        )
         self.root_page.add_child(instance=self.section_c)
 
         # Add test page A into section A
-        self.test_page_a = SimplePage(title="Hello world!", slug="hello-world", content="hello")
+        self.test_page_a = SimplePage(
+            title="Hello world!", slug="hello-world", content="hello"
+        )
         self.section_a.add_child(instance=self.test_page_a)
 
         # Add test page B into section C
-        self.test_page_b = SimplePage(title="Hello world!", slug="hello-world", content="hello")
+        self.test_page_b = SimplePage(
+            title="Hello world!", slug="hello-world", content="hello"
+        )
         self.section_c.add_child(instance=self.test_page_b)
 
         # Add unpublished page to the root with a child page
-        self.unpublished_page = SimplePage(title="Unpublished", slug="unpublished", content="hello")
+        self.unpublished_page = SimplePage(
+            title="Unpublished", slug="unpublished", content="hello"
+        )
         sub_page = SimplePage(title="Sub Page", slug="sub-page", content="child")
         self.root_page.add_child(instance=self.unpublished_page)
         self.unpublished_page.add_child(instance=sub_page)
@@ -53,11 +65,15 @@ class TestPageMove(TestCase, WagtailTestUtils):
         self.user = self.login()
 
     def test_page_move(self):
-        response = self.client.get(reverse('wagtailadmin_pages:move', args=(self.test_page_a.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:move", args=(self.test_page_a.id,))
+        )
         self.assertEqual(response.status_code, 200)
 
     def test_page_move_default_destination(self):
-        response = self.client.get(reverse('wagtailadmin_pages:move', args=(self.test_page_b.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:move", args=(self.test_page_b.id,))
+        )
         self.assertEqual(response.status_code, 200)
         # The default destination is the parent of the page being moved
         self.assertEqual(response.context["viewed_page"].specific, self.section_c)
@@ -68,8 +84,8 @@ class TestPageMove(TestCase, WagtailTestUtils):
 
         for destination_page in destinations:
             move_url = reverse(
-                'wagtailadmin_pages:move_choose_destination',
-                args=(self.test_page_b.id, destination_page.id)
+                "wagtailadmin_pages:move_choose_destination",
+                args=(self.test_page_b.id, destination_page.id),
             )
             self.assertContains(response, move_url)
 
@@ -77,12 +93,16 @@ class TestPageMove(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         # Get move page
-        response = self.client.get(reverse('wagtailadmin_pages:move', args=(self.test_page_a.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:move", args=(self.test_page_a.id,))
+        )
 
         # Check that the user received a 302 redirected response
         self.assertEqual(response.status_code, 302)
@@ -91,7 +111,7 @@ class TestPageMove(TestCase, WagtailTestUtils):
         # to verify that a user without bulk delete permission is able to move a page with a child page
 
         self.client.logout()
-        user = get_user_model().objects.get(email='siteeditor@example.com')
+        user = get_user_model().objects.get(email="siteeditor@example.com")
         self.login(user)
 
         # ensure the bulk_delete is not applicable to this user
@@ -99,25 +119,31 @@ class TestPageMove(TestCase, WagtailTestUtils):
         self.assertFalse(can_bulk_delete)
 
         response = self.client.get(
-            reverse('wagtailadmin_pages:move', args=(self.unpublished_page.id, ))
+            reverse("wagtailadmin_pages:move", args=(self.unpublished_page.id,))
         )
 
         self.assertEqual(response.status_code, 200)
 
     def test_page_move_confirm(self):
         response = self.client.get(
-            reverse('wagtailadmin_pages:move_confirm', args=(self.test_page_a.id, self.section_b.id))
+            reverse(
+                "wagtailadmin_pages:move_confirm",
+                args=(self.test_page_a.id, self.section_b.id),
+            )
         )
         self.assertEqual(response.status_code, 200)
 
         response = self.client.get(
-            reverse('wagtailadmin_pages:move_confirm', args=(self.test_page_b.id, self.section_a.id))
+            reverse(
+                "wagtailadmin_pages:move_confirm",
+                args=(self.test_page_b.id, self.section_a.id),
+            )
         )
         # Duplicate slugs triggers a redirect with an error message.
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('wagtailadmin_home'))
-        messages = list(response.context['messages'])
+        response = self.client.get(reverse("wagtailadmin_home"))
+        messages = list(response.context["messages"])
         self.assertEqual(len(messages), 1)
         self.assertEqual(messages[0].level, message_constants.ERROR)
         # Slug should be in error message.
@@ -134,7 +160,10 @@ class TestPageMove(TestCase, WagtailTestUtils):
         # Post to view to move page
         try:
             self.client.post(
-                reverse('wagtailadmin_pages:move_confirm', args=(self.test_page_a.id, self.section_b.id))
+                reverse(
+                    "wagtailadmin_pages:move_confirm",
+                    args=(self.test_page_a.id, self.section_b.id),
+                )
             )
         finally:
             # Disconnect mock handler to prevent cross-test pollution
@@ -143,28 +172,34 @@ class TestPageMove(TestCase, WagtailTestUtils):
 
         # Check that the pre_page_move signal was fired
         self.assertEqual(pre_moved_handler.call_count, 1)
-        self.assertTrue(pre_moved_handler.called_with(
-            sender=self.test_page_a.specific_class,
-            instance=self.test_page_a,
-            parent_page_before=self.section_a,
-            parent_page_after=self.section_b,
-            url_path_before='/home/section-a/hello-world/',
-            url_path_after='/home/section-b/hello-world/',
-        ))
+        self.assertTrue(
+            pre_moved_handler.called_with(
+                sender=self.test_page_a.specific_class,
+                instance=self.test_page_a,
+                parent_page_before=self.section_a,
+                parent_page_after=self.section_b,
+                url_path_before="/home/section-a/hello-world/",
+                url_path_after="/home/section-b/hello-world/",
+            )
+        )
 
         # Check that the post_page_move signal was fired
         self.assertEqual(post_moved_handler.call_count, 1)
-        self.assertTrue(post_moved_handler.called_with(
-            sender=self.test_page_a.specific_class,
-            instance=self.test_page_a,
-            parent_page_before=self.section_a,
-            parent_page_after=self.section_b,
-            url_path_before='/home/section-a/hello-world/',
-            url_path_after='/home/section-b/hello-world/',
-        ))
+        self.assertTrue(
+            post_moved_handler.called_with(
+                sender=self.test_page_a.specific_class,
+                instance=self.test_page_a,
+                parent_page_before=self.section_a,
+                parent_page_after=self.section_b,
+                url_path_before="/home/section-a/hello-world/",
+                url_path_after="/home/section-b/hello-world/",
+            )
+        )
 
     def test_page_set_page_position(self):
-        response = self.client.get(reverse('wagtailadmin_pages:set_page_position', args=(self.test_page_a.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:set_page_position", args=(self.test_page_a.id,))
+        )
         self.assertEqual(response.status_code, 200)
 
     def test_before_move_page_hook(self):
@@ -175,8 +210,13 @@ class TestPageMove(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_move_page', hook_func):
-            response = self.client.get(reverse('wagtailadmin_pages:move_confirm', args=(self.test_page_a.id, self.section_b.id)))
+        with self.register_hook("before_move_page", hook_func):
+            response = self.client.get(
+                reverse(
+                    "wagtailadmin_pages:move_confirm",
+                    args=(self.test_page_a.id, self.section_b.id),
+                )
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
@@ -189,16 +229,20 @@ class TestPageMove(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_move_page', hook_func):
-            response = self.client.post(reverse('wagtailadmin_pages:move_confirm', args=(self.test_page_a.id, self.section_b.id)))
+        with self.register_hook("before_move_page", hook_func):
+            response = self.client.post(
+                reverse(
+                    "wagtailadmin_pages:move_confirm",
+                    args=(self.test_page_a.id, self.section_b.id),
+                )
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
 
         # page should not be moved
         self.assertEqual(
-            Page.objects.get(id=self.test_page_a.id).get_parent().id,
-            self.section_a.id
+            Page.objects.get(id=self.test_page_a.id).get_parent().id, self.section_a.id
         )
 
     def test_after_move_page_hook(self):
@@ -208,14 +252,18 @@ class TestPageMove(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_move_page', hook_func):
-            response = self.client.post(reverse('wagtailadmin_pages:move_confirm', args=(self.test_page_a.id, self.section_b.id)))
+        with self.register_hook("after_move_page", hook_func):
+            response = self.client.post(
+                reverse(
+                    "wagtailadmin_pages:move_confirm",
+                    args=(self.test_page_a.id, self.section_b.id),
+                )
+            )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b"Overridden!")
 
         # page should be moved
         self.assertEqual(
-            Page.objects.get(id=self.test_page_a.id).get_parent().id,
-            self.section_b.id
+            Page.objects.get(id=self.test_page_a.id).get_parent().id, self.section_b.id
         )

+ 82 - 38
wagtail/admin/tests/pages/test_page_locking.py

@@ -19,17 +19,21 @@ class TestLocking(TestCase, WagtailTestUtils):
         # Create a page and submit it for moderation
         self.child_page = SimplePage(
             title="Hello world!",
-            slug='hello-world',
+            slug="hello-world",
             content="hello",
             live=False,
         )
         self.root_page.add_child(instance=self.child_page)
 
     def test_lock_post(self):
-        response = self.client.post(reverse('wagtailadmin_pages:lock', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:lock", args=(self.child_page.id,))
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page is locked
         page = Page.objects.get(id=self.child_page.id)
@@ -38,7 +42,9 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.assertIsNotNone(page.locked_at)
 
     def test_lock_get(self):
-        response = self.client.get(reverse('wagtailadmin_pages:lock', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:lock", args=(self.child_page.id,))
+        )
 
         # Check response
         self.assertEqual(response.status_code, 405)
@@ -56,10 +62,14 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:lock', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:lock", args=(self.child_page.id,))
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page is still locked
         page = Page.objects.get(id=self.child_page.id)
@@ -68,12 +78,15 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.assertIsNotNone(page.locked_at)
 
     def test_lock_post_with_good_redirect(self):
-        response = self.client.post(reverse('wagtailadmin_pages:lock', args=(self.child_page.id, )), {
-            'next': reverse('wagtailadmin_pages:edit', args=(self.child_page.id, ))
-        })
+        response = self.client.post(
+            reverse("wagtailadmin_pages:lock", args=(self.child_page.id,)),
+            {"next": reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))},
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=(self.child_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
+        )
 
         # Check that the page is locked
         page = Page.objects.get(id=self.child_page.id)
@@ -82,12 +95,15 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.assertIsNotNone(page.locked_at)
 
     def test_lock_post_with_bad_redirect(self):
-        response = self.client.post(reverse('wagtailadmin_pages:lock', args=(self.child_page.id, )), {
-            'next': 'http://www.google.co.uk'
-        })
+        response = self.client.post(
+            reverse("wagtailadmin_pages:lock", args=(self.child_page.id,)),
+            {"next": "http://www.google.co.uk"},
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page is locked
         page = Page.objects.get(id=self.child_page.id)
@@ -96,7 +112,7 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.assertIsNotNone(page.locked_at)
 
     def test_lock_post_bad_page(self):
-        response = self.client.post(reverse('wagtailadmin_pages:lock', args=(9999, )))
+        response = self.client.post(reverse("wagtailadmin_pages:lock", args=(9999,)))
 
         # Check response
         self.assertEqual(response.status_code, 404)
@@ -111,11 +127,15 @@ class TestLocking(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:lock', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:lock", args=(self.child_page.id,))
+        )
 
         # Check response
         self.assertEqual(response.status_code, 302)
@@ -131,10 +151,13 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_by = self.user
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
-        response = self.client.get(reverse('wagtailadmin_home'))
+        response = self.client.get(reverse("wagtailadmin_home"))
         self.assertContains(response, "Your locked pages")
         # check that LockUnlockAction is present and passes a valid csrf token
-        self.assertRegex(response.content.decode('utf-8'), r"LockUnlockAction\(\'\w+\'\, \'\/admin\/'\)")
+        self.assertRegex(
+            response.content.decode("utf-8"),
+            r"LockUnlockAction\(\'\w+\'\, \'\/admin\/'\)",
+        )
 
     def test_unlock_post(self):
         # Lock the page
@@ -143,10 +166,14 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:unlock', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unlock", args=(self.child_page.id,))
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page is unlocked
         page = Page.objects.get(id=self.child_page.id)
@@ -161,7 +188,9 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.get(reverse('wagtailadmin_pages:unlock', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:unlock", args=(self.child_page.id,))
+        )
 
         # Check response
         self.assertEqual(response.status_code, 405)
@@ -173,10 +202,14 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.assertIsNotNone(page.locked_at)
 
     def test_unlock_post_already_unlocked(self):
-        response = self.client.post(reverse('wagtailadmin_pages:unlock', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unlock", args=(self.child_page.id,))
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page is still unlocked
         page = Page.objects.get(id=self.child_page.id)
@@ -191,12 +224,15 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:unlock', args=(self.child_page.id, )), {
-            'next': reverse('wagtailadmin_pages:edit', args=(self.child_page.id, ))
-        })
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unlock", args=(self.child_page.id,)),
+            {"next": reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))},
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=(self.child_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
+        )
 
         # Check that the page is unlocked
         page = Page.objects.get(id=self.child_page.id)
@@ -211,12 +247,15 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:unlock', args=(self.child_page.id, )), {
-            'next': 'http://www.google.co.uk'
-        })
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unlock", args=(self.child_page.id,)),
+            {"next": "http://www.google.co.uk"},
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page is unlocked
         page = Page.objects.get(id=self.child_page.id)
@@ -231,7 +270,7 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:unlock', args=(9999, )))
+        response = self.client.post(reverse("wagtailadmin_pages:unlock", args=(9999,)))
 
         # Check response
         self.assertEqual(response.status_code, 404)
@@ -253,7 +292,9 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:unlock', args=(self.child_page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unlock", args=(self.child_page.id,))
+        )
 
         # Check response
         self.assertEqual(response.status_code, 302)
@@ -277,12 +318,15 @@ class TestLocking(TestCase, WagtailTestUtils):
         self.child_page.locked_at = timezone.now()
         self.child_page.save()
 
-        response = self.client.post(reverse('wagtailadmin_pages:unlock', args=(self.child_page.id, )), {
-            'next': reverse('wagtailadmin_pages:edit', args=(self.child_page.id, ))
-        })
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unlock", args=(self.child_page.id,)),
+            {"next": reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))},
+        )
 
         # Check response
-        self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=(self.child_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
+        )
 
         # Check that the page is still locked
         page = Page.objects.get(id=self.child_page.id)

+ 85 - 61
wagtail/admin/tests/pages/test_page_search.py

@@ -14,65 +14,73 @@ class TestPageSearch(TestCase, WagtailTestUtils):
         self.user = self.login()
 
     def get(self, params=None, **extra):
-        return self.client.get(reverse('wagtailadmin_pages:search'), params or {}, **extra)
+        return self.client.get(
+            reverse("wagtailadmin_pages:search"), params or {}, **extra
+        )
 
     def test_view(self):
         response = self.get()
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/search.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/search.html")
         self.assertEqual(response.status_code, 200)
 
     def test_search(self):
-        response = self.get({'q': "Hello"})
+        response = self.get({"q": "Hello"})
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/search.html')
-        self.assertEqual(response.context['query_string'], "Hello")
+        self.assertTemplateUsed(response, "wagtailadmin/pages/search.html")
+        self.assertEqual(response.context["query_string"], "Hello")
 
     def test_search_searchable_fields(self):
         # Find root page
         root_page = Page.objects.get(id=2)
 
         # Create a page
-        root_page.add_child(instance=SimplePage(
-            title="Hi there!", slug='hello-world', content="good morning",
-            live=True,
-            has_unpublished_changes=False,
-        ))
+        root_page.add_child(
+            instance=SimplePage(
+                title="Hi there!",
+                slug="hello-world",
+                content="good morning",
+                live=True,
+                has_unpublished_changes=False,
+            )
+        )
 
         # Confirm the slug is not being searched
-        response = self.get({'q': "hello"})
+        response = self.get({"q": "hello"})
         self.assertNotContains(response, "There is one matching page")
         search_fields = Page.search_fields
 
         # Add slug to the search_fields
-        Page.search_fields = Page.search_fields + [SearchField('slug', partial_match=True)]
+        Page.search_fields = Page.search_fields + [
+            SearchField("slug", partial_match=True)
+        ]
 
         # Confirm the slug is being searched
-        response = self.get({'q': "hello"})
+        response = self.get({"q": "hello"})
         self.assertContains(response, "There is one matching page")
 
         # Reset the search fields
         Page.search_fields = search_fields
 
     def test_ajax(self):
-        response = self.get({'q': "Hello"}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+        response = self.get({"q": "Hello"}, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateNotUsed(response, 'wagtailadmin/pages/search.html')
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/search_results.html')
-        self.assertEqual(response.context['query_string'], "Hello")
+        self.assertTemplateNotUsed(response, "wagtailadmin/pages/search.html")
+        self.assertTemplateUsed(response, "wagtailadmin/pages/search_results.html")
+        self.assertEqual(response.context["query_string"], "Hello")
 
     def test_pagination(self):
-        pages = ['0', '1', '-1', '9999', 'Not a page']
+        pages = ["0", "1", "-1", "9999", "Not a page"]
         for page in pages:
-            response = self.get({'q': "Hello", 'p': page})
+            response = self.get({"q": "Hello", "p": page})
             self.assertEqual(response.status_code, 200)
-            self.assertTemplateUsed(response, 'wagtailadmin/pages/search.html')
+            self.assertTemplateUsed(response, "wagtailadmin/pages/search.html")
 
     def test_root_can_appear_in_search_results(self):
-        response = self.get({'q': "roo"})
+        response = self.get({"q": "roo"})
         self.assertEqual(response.status_code, 200)
         # 'pages' list in the response should contain root
-        results = response.context['pages']
-        self.assertTrue(any([r.slug == 'root' for r in results]))
+        results = response.context["pages"]
+        self.assertTrue(any([r.slug == "root" for r in results]))
 
     def test_search_uses_admin_display_title_from_specific_class(self):
         # SingleEventPage has a custom get_admin_display_title method; explorer should
@@ -80,107 +88,123 @@ class TestPageSearch(TestCase, WagtailTestUtils):
         root_page = Page.objects.get(id=2)
         new_event = SingleEventPage(
             title="Lunar event",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
-            latest_revision_created_at=local_datetime(2016, 1, 1)
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
+            latest_revision_created_at=local_datetime(2016, 1, 1),
         )
         root_page.add_child(instance=new_event)
-        response = self.get({'q': "lunar"})
+        response = self.get({"q": "lunar"})
         self.assertContains(response, "Lunar event (single event)")
 
     def test_search_no_perms(self):
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
-        self.assertRedirects(self.get(), '/admin/')
+        self.assertRedirects(self.get(), "/admin/")
 
     def test_search_order_by_title(self):
         root_page = Page.objects.get(id=2)
         new_event = SingleEventPage(
             title="Lunar event",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
-            latest_revision_created_at=local_datetime(2016, 1, 1)
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
+            latest_revision_created_at=local_datetime(2016, 1, 1),
         )
         root_page.add_child(instance=new_event)
 
         new_event_2 = SingleEventPage(
             title="A Lunar event",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
-            latest_revision_created_at=local_datetime(2016, 1, 1)
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
+            latest_revision_created_at=local_datetime(2016, 1, 1),
         )
         root_page.add_child(instance=new_event_2)
 
-        response = self.get({'q': 'Lunar', 'ordering': 'title'})
-        page_ids = [page.id for page in response.context['pages']]
+        response = self.get({"q": "Lunar", "ordering": "title"})
+        page_ids = [page.id for page in response.context["pages"]]
         self.assertEqual(page_ids, [new_event_2.id, new_event.id])
 
-        response = self.get({'q': 'Lunar', 'ordering': '-title'})
-        page_ids = [page.id for page in response.context['pages']]
+        response = self.get({"q": "Lunar", "ordering": "-title"})
+        page_ids = [page.id for page in response.context["pages"]]
         self.assertEqual(page_ids, [new_event.id, new_event_2.id])
 
     def test_search_order_by_updated(self):
         root_page = Page.objects.get(id=2)
         new_event = SingleEventPage(
             title="Lunar event",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
-            latest_revision_created_at=local_datetime(2016, 1, 1)
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
+            latest_revision_created_at=local_datetime(2016, 1, 1),
         )
         root_page.add_child(instance=new_event)
 
         new_event_2 = SingleEventPage(
             title="Lunar event 2",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
-            latest_revision_created_at=local_datetime(2015, 1, 1)
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
+            latest_revision_created_at=local_datetime(2015, 1, 1),
         )
         root_page.add_child(instance=new_event_2)
 
-        response = self.get({'q': 'Lunar', 'ordering': 'latest_revision_created_at'})
-        page_ids = [page.id for page in response.context['pages']]
+        response = self.get({"q": "Lunar", "ordering": "latest_revision_created_at"})
+        page_ids = [page.id for page in response.context["pages"]]
         self.assertEqual(page_ids, [new_event_2.id, new_event.id])
 
-        response = self.get({'q': 'Lunar', 'ordering': '-latest_revision_created_at'})
-        page_ids = [page.id for page in response.context['pages']]
+        response = self.get({"q": "Lunar", "ordering": "-latest_revision_created_at"})
+        page_ids = [page.id for page in response.context["pages"]]
         self.assertEqual(page_ids, [new_event.id, new_event_2.id])
 
     def test_search_order_by_status(self):
         root_page = Page.objects.get(id=2)
         live_event = SingleEventPage(
             title="Lunar event",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
             latest_revision_created_at=local_datetime(2016, 1, 1),
-            live=True
+            live=True,
         )
         root_page.add_child(instance=live_event)
 
         draft_event = SingleEventPage(
             title="Lunar event",
-            location='the moon', audience='public',
-            cost='free', date_from='2001-01-01',
+            location="the moon",
+            audience="public",
+            cost="free",
+            date_from="2001-01-01",
             latest_revision_created_at=local_datetime(2016, 1, 1),
-            live=False
+            live=False,
         )
         root_page.add_child(instance=draft_event)
 
-        response = self.get({'q': 'Lunar', 'ordering': 'live'})
-        page_ids = [page.id for page in response.context['pages']]
+        response = self.get({"q": "Lunar", "ordering": "live"})
+        page_ids = [page.id for page in response.context["pages"]]
         self.assertEqual(page_ids, [draft_event.id, live_event.id])
 
-        response = self.get({'q': 'Lunar', 'ordering': '-live'})
-        page_ids = [page.id for page in response.context['pages']]
+        response = self.get({"q": "Lunar", "ordering": "-live"})
+        page_ids = [page.id for page in response.context["pages"]]
         self.assertEqual(page_ids, [live_event.id, draft_event.id])
 
     def test_search_filter_content_type(self):
         # Correct content_type
-        response = self.get({'content_type': "demosite.standardpage"})
+        response = self.get({"content_type": "demosite.standardpage"})
         self.assertEqual(response.status_code, 200)
 
         # Incorrect content_type
-        response = self.get({'content_type': "demosite.standardpage.error"})
+        response = self.get({"content_type": "demosite.standardpage.error"})
         self.assertEqual(response.status_code, 404)

+ 173 - 104
wagtail/admin/tests/pages/test_preview.py

@@ -1,5 +1,4 @@
 import datetime
-
 from functools import wraps
 from unittest import mock
 
@@ -11,7 +10,12 @@ from freezegun import freeze_time
 from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface
 from wagtail.admin.views.pages.preview import PreviewOnEdit
 from wagtail.core.models import Page
-from wagtail.tests.testapp.models import EventCategory, EventPage, SimplePage, StreamPage
+from wagtail.tests.testapp.models import (
+    EventCategory,
+    EventPage,
+    SimplePage,
+    StreamPage,
+)
 from wagtail.tests.utils import WagtailTestUtils
 
 
@@ -26,39 +30,41 @@ class TestIssue2599(TestCase, WagtailTestUtils):
     def test_issue_2599(self):
         homepage = Page.objects.get(id=2)
 
-        child1 = Page(title='child1')
+        child1 = Page(title="child1")
         homepage.add_child(instance=child1)
-        child2 = Page(title='child2')
+        child2 = Page(title="child2")
         homepage.add_child(instance=child2)
 
         child1.delete()
 
         self.login()
         post_data = {
-            'title': "New page!",
-            'content': "Some content",
-            'slug': 'hello-world',
-            'action-submit': "Submit",
+            "title": "New page!",
+            "content": "Some content",
+            "slug": "hello-world",
+            "action-submit": "Submit",
         }
-        preview_url = reverse('wagtailadmin_pages:preview_on_add',
-                              args=('tests', 'simplepage', homepage.id))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_on_add",
+            args=("tests", "simplepage", homepage.id),
+        )
         response = self.client.post(preview_url, post_data)
 
         # Check the JSON response
         self.assertEqual(response.status_code, 200)
-        self.assertJSONEqual(response.content.decode(), {'is_valid': True})
+        self.assertJSONEqual(response.content.decode(), {"is_valid": True})
 
         response = self.client.get(preview_url)
 
         # Check the HTML response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'tests/simple_page.html')
+        self.assertTemplateUsed(response, "tests/simple_page.html")
         self.assertContains(response, "New page!")
 
         # Check that the treebeard attributes were set correctly on the page object
-        self.assertEqual(response.context['self'].depth, homepage.depth + 1)
-        self.assertTrue(response.context['self'].path.startswith(homepage.path))
-        self.assertEqual(response.context['self'].get_parent(), homepage)
+        self.assertEqual(response.context["self"].depth, homepage.depth + 1)
+        self.assertTrue(response.context["self"].path.startswith(homepage.path))
+        self.assertEqual(response.context["self"].get_parent(), homepage)
 
 
 def clear_edit_handler(page_cls):
@@ -72,96 +78,103 @@ def clear_edit_handler(page_cls):
             finally:
                 # Clear the bad EditHandler generated just now
                 page_cls.get_edit_handler.cache_clear()
+
         return decorated
+
     return decorator
 
 
 class TestPreview(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.meetings_category = EventCategory.objects.create(name='Meetings')
-        self.parties_category = EventCategory.objects.create(name='Parties')
-        self.holidays_category = EventCategory.objects.create(name='Holidays')
+        self.meetings_category = EventCategory.objects.create(name="Meetings")
+        self.parties_category = EventCategory.objects.create(name="Parties")
+        self.holidays_category = EventCategory.objects.create(name="Holidays")
 
-        self.home_page = Page.objects.get(url_path='/home/')
-        self.event_page = Page.objects.get(url_path='/home/events/christmas/')
+        self.home_page = Page.objects.get(url_path="/home/")
+        self.event_page = Page.objects.get(url_path="/home/events/christmas/")
 
         self.user = self.login()
 
         self.post_data = {
-            'title': "Beach party",
-            'slug': 'beach-party',
-            'body': '''{"entityMap": {},"blocks": [
+            "title": "Beach party",
+            "slug": "beach-party",
+            "body": """{"entityMap": {},"blocks": [
                 {"inlineStyleRanges": [], "text": "party on wayne", "depth": 0, "type": "unstyled", "key": "00000", "entityRanges": []}
-            ]}''',
-            'date_from': '2017-08-01',
-            'audience': 'public',
-            'location': 'the beach',
-            'cost': 'six squid',
-            'carousel_items-TOTAL_FORMS': 0,
-            'carousel_items-INITIAL_FORMS': 0,
-            'carousel_items-MIN_NUM_FORMS': 0,
-            'carousel_items-MAX_NUM_FORMS': 0,
-            'speakers-TOTAL_FORMS': 0,
-            'speakers-INITIAL_FORMS': 0,
-            'speakers-MIN_NUM_FORMS': 0,
-            'speakers-MAX_NUM_FORMS': 0,
-            'related_links-TOTAL_FORMS': 0,
-            'related_links-INITIAL_FORMS': 0,
-            'related_links-MIN_NUM_FORMS': 0,
-            'related_links-MAX_NUM_FORMS': 0,
-            'head_counts-TOTAL_FORMS': 0,
-            'head_counts-INITIAL_FORMS': 0,
-            'head_counts-MIN_NUM_FORMS': 0,
-            'head_counts-MAX_NUM_FORMS': 0,
-            'categories': [self.parties_category.id, self.holidays_category.id],
-            'comments-TOTAL_FORMS': 0,
-            'comments-INITIAL_FORMS': 0,
-            'comments-MIN_NUM_FORMS': 0,
-            'comments-MAX_NUM_FORMS': 1000,
+            ]}""",
+            "date_from": "2017-08-01",
+            "audience": "public",
+            "location": "the beach",
+            "cost": "six squid",
+            "carousel_items-TOTAL_FORMS": 0,
+            "carousel_items-INITIAL_FORMS": 0,
+            "carousel_items-MIN_NUM_FORMS": 0,
+            "carousel_items-MAX_NUM_FORMS": 0,
+            "speakers-TOTAL_FORMS": 0,
+            "speakers-INITIAL_FORMS": 0,
+            "speakers-MIN_NUM_FORMS": 0,
+            "speakers-MAX_NUM_FORMS": 0,
+            "related_links-TOTAL_FORMS": 0,
+            "related_links-INITIAL_FORMS": 0,
+            "related_links-MIN_NUM_FORMS": 0,
+            "related_links-MAX_NUM_FORMS": 0,
+            "head_counts-TOTAL_FORMS": 0,
+            "head_counts-INITIAL_FORMS": 0,
+            "head_counts-MIN_NUM_FORMS": 0,
+            "head_counts-MAX_NUM_FORMS": 0,
+            "categories": [self.parties_category.id, self.holidays_category.id],
+            "comments-TOTAL_FORMS": 0,
+            "comments-INITIAL_FORMS": 0,
+            "comments-MIN_NUM_FORMS": 0,
+            "comments-MAX_NUM_FORMS": 1000,
         }
 
     def test_preview_on_create_with_m2m_field(self):
-        preview_url = reverse('wagtailadmin_pages:preview_on_add',
-                              args=('tests', 'eventpage', self.home_page.id))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_on_add",
+            args=("tests", "eventpage", self.home_page.id),
+        )
         response = self.client.post(preview_url, self.post_data)
 
         # Check the JSON response
         self.assertEqual(response.status_code, 200)
-        self.assertJSONEqual(response.content.decode(), {'is_valid': True})
+        self.assertJSONEqual(response.content.decode(), {"is_valid": True})
 
         # Check the user can refresh the preview
-        preview_session_key = 'wagtail-preview-tests-eventpage-{}'.format(self.home_page.id)
+        preview_session_key = "wagtail-preview-tests-eventpage-{}".format(
+            self.home_page.id
+        )
         self.assertIn(preview_session_key, self.client.session)
 
         response = self.client.get(preview_url)
 
         # Check the HTML response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'tests/event_page.html')
+        self.assertTemplateUsed(response, "tests/event_page.html")
         self.assertContains(response, "Beach party")
         self.assertContains(response, "<li>Parties</li>")
         self.assertContains(response, "<li>Holidays</li>")
 
     def test_preview_on_edit_with_m2m_field(self):
-        preview_url = reverse('wagtailadmin_pages:preview_on_edit',
-                              args=(self.event_page.id,))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_on_edit", args=(self.event_page.id,)
+        )
         response = self.client.post(preview_url, self.post_data)
 
         # Check the JSON response
         self.assertEqual(response.status_code, 200)
-        self.assertJSONEqual(response.content.decode(), {'is_valid': True})
+        self.assertJSONEqual(response.content.decode(), {"is_valid": True})
 
         # Check the user can refresh the preview
-        preview_session_key = 'wagtail-preview-{}'.format(self.event_page.id)
+        preview_session_key = "wagtail-preview-{}".format(self.event_page.id)
         self.assertIn(preview_session_key, self.client.session)
 
         response = self.client.get(preview_url)
 
         # Check the HTML response
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'tests/event_page.html')
+        self.assertTemplateUsed(response, "tests/event_page.html")
         self.assertContains(response, "Beach party")
         self.assertContains(response, "<li>Parties</li>")
         self.assertContains(response, "<li>Holidays</li>")
@@ -169,11 +182,13 @@ class TestPreview(TestCase, WagtailTestUtils):
     def test_preview_on_edit_expiry(self):
         initial_datetime = timezone.now()
         expiry_datetime = initial_datetime + datetime.timedelta(
-            seconds=PreviewOnEdit.preview_expiration_timeout + 1)
+            seconds=PreviewOnEdit.preview_expiration_timeout + 1
+        )
 
         with freeze_time(initial_datetime) as frozen_datetime:
-            preview_url = reverse('wagtailadmin_pages:preview_on_edit',
-                                  args=(self.event_page.id,))
+            preview_url = reverse(
+                "wagtailadmin_pages:preview_on_edit", args=(self.event_page.id,)
+            )
             response = self.client.post(preview_url, self.post_data)
 
             # Check the JSON response
@@ -186,8 +201,9 @@ class TestPreview(TestCase, WagtailTestUtils):
 
             frozen_datetime.move_to(expiry_datetime)
 
-            preview_url = reverse('wagtailadmin_pages:preview_on_edit',
-                                  args=(self.home_page.id,))
+            preview_url = reverse(
+                "wagtailadmin_pages:preview_on_edit", args=(self.home_page.id,)
+            )
             response = self.client.post(preview_url, self.post_data)
             self.assertEqual(response.status_code, 200)
             response = self.client.get(preview_url)
@@ -206,7 +222,11 @@ class TestPreview(TestCase, WagtailTestUtils):
                 new_children = []
                 for child in self.children:
                     # skip the "categories" FieldPanel for non-superusers
-                    if isinstance(child, FieldPanel) and child.field_name == "categories" and not self.request.user.is_superuser:
+                    if (
+                        isinstance(child, FieldPanel)
+                        and child.field_name == "categories"
+                        and not self.request.user.is_superuser
+                    ):
                         continue
 
                     new_child = child.bind_to(
@@ -218,54 +238,64 @@ class TestPreview(TestCase, WagtailTestUtils):
                     new_children.append(new_child)
                 self.children = new_children
 
-        new_tabbed_interface = TabbedInterface([
-            SuperuserEventCategoriesObjectList(EventPage.content_panels),
-            ObjectList(EventPage.promote_panels),
-        ])
+        new_tabbed_interface = TabbedInterface(
+            [
+                SuperuserEventCategoriesObjectList(EventPage.content_panels),
+                ObjectList(EventPage.promote_panels),
+            ]
+        )
 
-        with mock.patch.object(EventPage, 'edit_handler', new=new_tabbed_interface, create=True):
+        with mock.patch.object(
+            EventPage, "edit_handler", new=new_tabbed_interface, create=True
+        ):
             # Non-superusers should not see categories panel, so even though "post_data" contains "categories",
             # it should not be considered for the preview request.
-            self.login(username='siteeditor', password='password')
+            self.login(username="siteeditor", password="password")
 
-            preview_url = reverse('wagtailadmin_pages:preview_on_add',
-                                  args=('tests', 'eventpage', self.home_page.id))
+            preview_url = reverse(
+                "wagtailadmin_pages:preview_on_add",
+                args=("tests", "eventpage", self.home_page.id),
+            )
             response = self.client.post(preview_url, self.post_data)
 
             # Check the JSON response
             self.assertEqual(response.status_code, 200)
-            self.assertJSONEqual(response.content.decode(), {'is_valid': True})
+            self.assertJSONEqual(response.content.decode(), {"is_valid": True})
 
             # Check the user can refresh the preview
-            preview_session_key = 'wagtail-preview-tests-eventpage-{}'.format(self.home_page.id)
+            preview_session_key = "wagtail-preview-tests-eventpage-{}".format(
+                self.home_page.id
+            )
             self.assertIn(preview_session_key, self.client.session)
 
             response = self.client.get(preview_url)
 
             # Check the HTML response
             self.assertEqual(response.status_code, 200)
-            self.assertTemplateUsed(response, 'tests/event_page.html')
+            self.assertTemplateUsed(response, "tests/event_page.html")
             self.assertContains(response, "Beach party")
             self.assertNotContains(response, "<li>Parties</li>")
             self.assertNotContains(response, "<li>Holidays</li>")
 
             # Since superusers see the "categories" panel, the posted data should be used for the preview.
-            self.login(username='superuser', password='password')
+            self.login(username="superuser", password="password")
             response = self.client.post(preview_url, self.post_data)
 
             # Check the JSON response
             self.assertEqual(response.status_code, 200)
-            self.assertJSONEqual(response.content.decode(), {'is_valid': True})
+            self.assertJSONEqual(response.content.decode(), {"is_valid": True})
 
             # Check the user can refresh the preview
-            preview_session_key = 'wagtail-preview-tests-eventpage-{}'.format(self.home_page.id)
+            preview_session_key = "wagtail-preview-tests-eventpage-{}".format(
+                self.home_page.id
+            )
             self.assertIn(preview_session_key, self.client.session)
 
             response = self.client.get(preview_url)
 
             # Check the HTML response
             self.assertEqual(response.status_code, 200)
-            self.assertTemplateUsed(response, 'tests/event_page.html')
+            self.assertTemplateUsed(response, "tests/event_page.html")
             self.assertContains(response, "Beach party")
             self.assertContains(response, "<li>Parties</li>")
             self.assertContains(response, "<li>Holidays</li>")
@@ -275,6 +305,7 @@ class TestDisablePreviewButton(TestCase, WagtailTestUtils):
     """
     Test that preview button can be disabled by setting preview_modes to an empty list
     """
+
     def setUp(self):
         # Find root page
         self.root_page = Page.objects.get(id=2)
@@ -284,71 +315,109 @@ class TestDisablePreviewButton(TestCase, WagtailTestUtils):
 
     def test_disable_preview_on_create(self):
         # preview button is available by default
-        response = self.client.get(reverse('wagtailadmin_pages:add', args=('tests', 'simplepage', self.root_page.id)))
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:add",
+                args=("tests", "simplepage", self.root_page.id),
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
-        preview_url = reverse('wagtailadmin_pages:preview_on_add', args=('tests', 'simplepage', self.root_page.id))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_on_add",
+            args=("tests", "simplepage", self.root_page.id),
+        )
         self.assertContains(response, '<li class="preview">')
         self.assertContains(response, 'data-action="%s"' % preview_url)
 
         # StreamPage has preview_modes = []
-        response = self.client.get(reverse('wagtailadmin_pages:add', args=('tests', 'streampage', self.root_page.id)))
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:add",
+                args=("tests", "streampage", self.root_page.id),
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
-        preview_url = reverse('wagtailadmin_pages:preview_on_add', args=('tests', 'streampage', self.root_page.id))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_on_add",
+            args=("tests", "streampage", self.root_page.id),
+        )
         self.assertNotContains(response, '<li class="preview">')
         self.assertNotContains(response, 'data-action="%s"' % preview_url)
 
     def test_disable_preview_on_edit(self):
-        simple_page = SimplePage(title='simple page', content="hello")
+        simple_page = SimplePage(title="simple page", content="hello")
         self.root_page.add_child(instance=simple_page)
 
         # preview button is available by default
-        response = self.client.get(reverse('wagtailadmin_pages:edit', args=(simple_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:edit", args=(simple_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
 
-        preview_url = reverse('wagtailadmin_pages:preview_on_edit', args=(simple_page.id, ))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_on_edit", args=(simple_page.id,)
+        )
         self.assertContains(response, '<li class="preview">')
         self.assertContains(response, 'data-action="%s"' % preview_url)
 
-        stream_page = StreamPage(title='stream page', body=[('text', 'hello')])
+        stream_page = StreamPage(title="stream page", body=[("text", "hello")])
         self.root_page.add_child(instance=stream_page)
 
         # StreamPage has preview_modes = []
-        response = self.client.get(reverse('wagtailadmin_pages:edit', args=(stream_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:edit", args=(stream_page.id,))
+        )
         self.assertEqual(response.status_code, 200)
 
-        preview_url = reverse('wagtailadmin_pages:preview_on_edit', args=(stream_page.id, ))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_on_edit", args=(stream_page.id,)
+        )
         self.assertNotContains(response, '<li class="preview">')
         self.assertNotContains(response, 'data-action="%s"' % preview_url)
 
     def test_disable_preview_on_revisions_list(self):
-        simple_page = SimplePage(title='simple page', content="hello")
+        simple_page = SimplePage(title="simple page", content="hello")
         self.root_page.add_child(instance=simple_page)
         simple_page.save_revision(log_action=True)
 
         # check preview shows up by default
-        response = self.client.get(reverse('wagtailadmin_pages:history', args=(simple_page.id,)))
-        preview_url = reverse('wagtailadmin_pages:revisions_view', args=(simple_page.id, simple_page.get_latest_revision().id))
-        self.assertContains(response, 'Preview')
+        response = self.client.get(
+            reverse("wagtailadmin_pages:history", args=(simple_page.id,))
+        )
+        preview_url = reverse(
+            "wagtailadmin_pages:revisions_view",
+            args=(simple_page.id, simple_page.get_latest_revision().id),
+        )
+        self.assertContains(response, "Preview")
         self.assertContains(response, preview_url)
 
-        stream_page = StreamPage(title='stream page', body=[('text', 'hello')])
+        stream_page = StreamPage(title="stream page", body=[("text", "hello")])
         self.root_page.add_child(instance=stream_page)
         latest_revision = stream_page.save_revision(log_action=True)
 
         # StreamPage has preview_modes = []
-        response = self.client.get(reverse('wagtailadmin_pages:history', args=(stream_page.id,)))
-        preview_url = reverse('wagtailadmin_pages:revisions_view', args=(stream_page.id, latest_revision.id))
-        self.assertNotContains(response, 'Preview')
+        response = self.client.get(
+            reverse("wagtailadmin_pages:history", args=(stream_page.id,))
+        )
+        preview_url = reverse(
+            "wagtailadmin_pages:revisions_view",
+            args=(stream_page.id, latest_revision.id),
+        )
+        self.assertNotContains(response, "Preview")
         self.assertNotContains(response, preview_url)
 
     def disable_preview_in_moderation_list(self):
-        stream_page = StreamPage(title='stream page', body=[('text', 'hello')])
+        stream_page = StreamPage(title="stream page", body=[("text", "hello")])
         self.root_page.add_child(instance=stream_page)
-        latest_revision = stream_page.save_revision(user=self.user, submitted_for_moderation=True)
-
-        response = self.client.get(reverse('wagtailadmin_home'))
-        preview_url = reverse('wagtailadmin_pages:preview_for_moderation', args=(latest_revision.id,))
+        latest_revision = stream_page.save_revision(
+            user=self.user, submitted_for_moderation=True
+        )
+
+        response = self.client.get(reverse("wagtailadmin_home"))
+        preview_url = reverse(
+            "wagtailadmin_pages:preview_for_moderation", args=(latest_revision.id,)
+        )
         self.assertNotContains(response, '<li class="preview">')
         self.assertNotContains(response, 'data-action="%s"' % preview_url)

+ 182 - 107
wagtail/admin/tests/pages/test_revisions.py

@@ -7,17 +7,21 @@ from freezegun import freeze_time
 
 from wagtail.admin.tests.pages.timestamps import local_datetime
 from wagtail.core.models import Page
-from wagtail.tests.testapp.models import EventPage, FormClassAdditionalFieldPage, SecretPage
+from wagtail.tests.testapp.models import (
+    EventPage,
+    FormClassAdditionalFieldPage,
+    SecretPage,
+)
 from wagtail.tests.utils import WagtailTestUtils
 
 
 class TestRevisions(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.christmas_event = EventPage.objects.get(url_path='/home/events/christmas/')
+        self.christmas_event = EventPage.objects.get(url_path="/home/events/christmas/")
         self.christmas_event.title = "Last Christmas"
-        self.christmas_event.date_from = '2013-12-25'
+        self.christmas_event.date_from = "2013-12-25"
         self.christmas_event.body = (
             "<p>Last Christmas I gave you my heart, "
             "but the very next day you gave it away</p>"
@@ -27,7 +31,7 @@ class TestRevisions(TestCase, WagtailTestUtils):
         self.last_christmas_revision.save()
 
         self.christmas_event.title = "This Christmas"
-        self.christmas_event.date_from = '2014-12-25'
+        self.christmas_event.date_from = "2014-12-25"
         self.christmas_event.body = (
             "<p>This year, to save me from tears, "
             "I'll give it to someone special</p>"
@@ -40,15 +44,19 @@ class TestRevisions(TestCase, WagtailTestUtils):
 
     def test_get_revisions_index(self):
         response = self.client.get(
-            reverse('wagtailadmin_pages:revisions_index', args=(self.christmas_event.id, ))
+            reverse(
+                "wagtailadmin_pages:revisions_index", args=(self.christmas_event.id,)
+            )
+        )
+        history_url = reverse(
+            "wagtailadmin_pages:history", args=(self.christmas_event.id,)
         )
-        history_url = reverse('wagtailadmin_pages:history', args=(self.christmas_event.id, ))
         self.assertRedirects(response, history_url)
 
     def request_preview_revision(self):
         last_christmas_preview_url = reverse(
-            'wagtailadmin_pages:revisions_view',
-            args=(self.christmas_event.id, self.last_christmas_revision.id)
+            "wagtailadmin_pages:revisions_view",
+            args=(self.christmas_event.id, self.last_christmas_revision.id),
         )
         return self.client.get(last_christmas_preview_url)
 
@@ -60,15 +68,11 @@ class TestRevisions(TestCase, WagtailTestUtils):
 
     def test_preview_revision_with_no_page_permissions_redirects_to_admin(self):
         admin_only_user = self.create_user(
-            username='admin_only',
-            email='admin_only@email.com',
-            password='password'
+            username="admin_only", email="admin_only@email.com", password="password"
         )
         admin_only_user.user_permissions.add(
             Permission.objects.get_by_natural_key(
-                codename='access_admin',
-                app_label='wagtailadmin',
-                model='admin'
+                codename="access_admin", app_label="wagtailadmin", model="admin"
             )
         )
 
@@ -76,15 +80,15 @@ class TestRevisions(TestCase, WagtailTestUtils):
         response = self.request_preview_revision()
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['Location'], reverse('wagtailadmin_home'))
+        self.assertEqual(response["Location"], reverse("wagtailadmin_home"))
 
     def test_preview_revision_forbidden_without_permission(self):
         # Alter the editors group so it has no permissions for Christmas page.
-        st_patricks = Page.objects.get(slug='saint-patrick')
-        editors_group = Group.objects.get(name='Site-wide editors')
+        st_patricks = Page.objects.get(slug="saint-patrick")
+        editors_group = Group.objects.get(name="Site-wide editors")
         editors_group.page_permissions.update(page_id=st_patricks.id)
 
-        editor = get_user_model().objects.get(email='siteeditor@example.com')
+        editor = get_user_model().objects.get(email="siteeditor@example.com")
 
         self.login(editor)
         response = self.request_preview_revision()
@@ -93,8 +97,8 @@ class TestRevisions(TestCase, WagtailTestUtils):
 
     def test_revert_revision(self):
         last_christmas_preview_url = reverse(
-            'wagtailadmin_pages:revisions_revert',
-            args=(self.christmas_event.id, self.last_christmas_revision.id)
+            "wagtailadmin_pages:revisions_revert",
+            args=(self.christmas_event.id, self.last_christmas_revision.id),
         )
         response = self.client.get(last_christmas_preview_url)
         self.assertEqual(response.status_code, 200)
@@ -107,8 +111,8 @@ class TestRevisions(TestCase, WagtailTestUtils):
 
         # Form should include a hidden 'revision' field
         revision_field = (
-            """<input type="hidden" name="revision" value="%d" />""" %
-            self.last_christmas_revision.id
+            """<input type="hidden" name="revision" value="%d" />"""
+            % self.last_christmas_revision.id
         )
         self.assertContains(response, revision_field)
 
@@ -120,33 +124,33 @@ class TestRevisions(TestCase, WagtailTestUtils):
     def test_scheduled_revision(self):
         if settings.USE_TZ:
             # 12:00 UTC
-            self.christmas_event.go_live_at = '2014-12-26T12:00:00.000Z'
+            self.christmas_event.go_live_at = "2014-12-26T12:00:00.000Z"
         else:
             # 12:00 in no specific timezone
-            self.christmas_event.go_live_at = '2014-12-26T12:00:00'
+            self.christmas_event.go_live_at = "2014-12-26T12:00:00"
         this_christmas_revision = self.christmas_event.save_revision(log_action=True)
         this_christmas_revision.publish(log_action=True)
         this_christmas_unschedule_url = reverse(
-            'wagtailadmin_pages:revisions_unschedule',
-            args=(self.christmas_event.id, this_christmas_revision.id)
+            "wagtailadmin_pages:revisions_unschedule",
+            args=(self.christmas_event.id, this_christmas_revision.id),
         )
         response = self.client.get(
-            reverse('wagtailadmin_pages:history', args=(self.christmas_event.id, ))
+            reverse("wagtailadmin_pages:history", args=(self.christmas_event.id,))
         )
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Page scheduled for publishing at 26 Dec 2014')
+        self.assertContains(response, "Page scheduled for publishing at 26 Dec 2014")
         self.assertContains(response, this_christmas_unschedule_url)
 
 
 class TestCompareRevisions(TestCase, WagtailTestUtils):
     # Actual tests for the comparison classes can be found in test_compare.py
 
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.christmas_event = EventPage.objects.get(url_path='/home/events/christmas/')
+        self.christmas_event = EventPage.objects.get(url_path="/home/events/christmas/")
         self.christmas_event.title = "Last Christmas"
-        self.christmas_event.date_from = '2013-12-25'
+        self.christmas_event.date_from = "2013-12-25"
         self.christmas_event.body = (
             "<p>Last Christmas I gave you my heart, "
             "but the very next day you gave it away</p>"
@@ -156,7 +160,7 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
         self.last_christmas_revision.save()
 
         self.christmas_event.title = "This Christmas"
-        self.christmas_event.date_from = '2014-12-25'
+        self.christmas_event.date_from = "2014-12-25"
         self.christmas_event.body = (
             "<p>This year, to save me from tears, "
             "I'll give it to someone special</p>"
@@ -169,8 +173,12 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
 
     def test_compare_revisions(self):
         compare_url = reverse(
-            'wagtailadmin_pages:revisions_compare',
-            args=(self.christmas_event.id, self.last_christmas_revision.id, self.this_christmas_revision.id)
+            "wagtailadmin_pages:revisions_compare",
+            args=(
+                self.christmas_event.id,
+                self.last_christmas_revision.id,
+                self.this_christmas_revision.id,
+            ),
         )
         response = self.client.get(compare_url)
         self.assertEqual(response.status_code, 200)
@@ -178,13 +186,13 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
         self.assertContains(
             response,
             '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>',
-            html=True
+            html=True,
         )
 
     def test_compare_revisions_earliest(self):
         compare_url = reverse(
-            'wagtailadmin_pages:revisions_compare',
-            args=(self.christmas_event.id, 'earliest', self.this_christmas_revision.id)
+            "wagtailadmin_pages:revisions_compare",
+            args=(self.christmas_event.id, "earliest", self.this_christmas_revision.id),
         )
         response = self.client.get(compare_url)
         self.assertEqual(response.status_code, 200)
@@ -192,13 +200,13 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
         self.assertContains(
             response,
             '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>',
-            html=True
+            html=True,
         )
 
     def test_compare_revisions_latest(self):
         compare_url = reverse(
-            'wagtailadmin_pages:revisions_compare',
-            args=(self.christmas_event.id, self.last_christmas_revision.id, 'latest')
+            "wagtailadmin_pages:revisions_compare",
+            args=(self.christmas_event.id, self.last_christmas_revision.id, "latest"),
         )
         response = self.client.get(compare_url)
         self.assertEqual(response.status_code, 200)
@@ -206,20 +214,19 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
         self.assertContains(
             response,
             '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>',
-            html=True
+            html=True,
         )
 
     def test_compare_revisions_live(self):
         # Mess with the live version, bypassing revisions
         self.christmas_event.body = (
-            "<p>This year, to save me from tears, "
-            "I'll just feed it to the dog</p>"
+            "<p>This year, to save me from tears, " "I'll just feed it to the dog</p>"
         )
-        self.christmas_event.save(update_fields=['body'])
+        self.christmas_event.save(update_fields=["body"])
 
         compare_url = reverse(
-            'wagtailadmin_pages:revisions_compare',
-            args=(self.christmas_event.id, self.last_christmas_revision.id, 'live')
+            "wagtailadmin_pages:revisions_compare",
+            args=(self.christmas_event.id, self.last_christmas_revision.id, "live"),
         )
         response = self.client.get(compare_url)
         self.assertEqual(response.status_code, 200)
@@ -227,15 +234,15 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
         self.assertContains(
             response,
             '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll just feed it to the dog</span>',
-            html=True
+            html=True,
         )
 
 
 class TestCompareRevisionsWithPerUserEditHandlers(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.home = Page.objects.get(url_path='/home/')
+        self.home = Page.objects.get(url_path="/home/")
         self.secret_page = SecretPage(
             title="Secret page",
             boring_data="InnocentCorp is the leading supplier of door hinges",
@@ -243,12 +250,14 @@ class TestCompareRevisionsWithPerUserEditHandlers(TestCase, WagtailTestUtils):
         )
         self.home.add_child(instance=self.secret_page)
         self.old_revision = self.secret_page.save_revision()
-        self.secret_page.boring_data = "InnocentCorp is the leading supplier of rubber sprockets"
+        self.secret_page.boring_data = (
+            "InnocentCorp is the leading supplier of rubber sprockets"
+        )
         self.secret_page.secret_data = "for fake moon landings"
         self.new_revision = self.secret_page.save_revision()
         self.compare_url = reverse(
-            'wagtailadmin_pages:revisions_compare',
-            args=(self.secret_page.id, self.old_revision.id, self.new_revision.id)
+            "wagtailadmin_pages:revisions_compare",
+            args=(self.secret_page.id, self.old_revision.id, self.new_revision.id),
         )
 
     def test_comparison_as_superuser(self):
@@ -259,18 +268,18 @@ class TestCompareRevisionsWithPerUserEditHandlers(TestCase, WagtailTestUtils):
         self.assertContains(
             response,
             'InnocentCorp is the leading supplier of <span class="deletion">door hinges</span><span class="addition">rubber sprockets</span>',
-            html=True
+            html=True,
         )
         self.assertContains(
             response,
             'for <span class="deletion">flying saucers</span><span class="addition">fake moon landings</span>',
-            html=True
+            html=True,
         )
 
     def test_comparison_as_ordinary_user(self):
-        user = self.create_user(username='editor', password='password')
-        user.groups.add(Group.objects.get(name='Site-wide editors'))
-        self.login(username='editor', password='password')
+        user = self.create_user(username="editor", password="password")
+        user.groups.add(Group.objects.get(name="Site-wide editors"))
+        self.login(username="editor", password="password")
 
         response = self.client.get(self.compare_url)
         self.assertEqual(response.status_code, 200)
@@ -278,11 +287,11 @@ class TestCompareRevisionsWithPerUserEditHandlers(TestCase, WagtailTestUtils):
         self.assertContains(
             response,
             'InnocentCorp is the leading supplier of <span class="deletion">door hinges</span><span class="addition">rubber sprockets</span>',
-            html=True
+            html=True,
         )
         self.assertNotContains(
             response,
-            'moon landings',
+            "moon landings",
         )
 
 
@@ -294,7 +303,7 @@ class TestCompareRevisionsWithNonModelField(TestCase, WagtailTestUtils):
     Note: Actual tests for comparison classes can be found in test_compare.py
     """
 
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
     # FormClassAdditionalFieldPage
 
     def setUp(self):
@@ -304,29 +313,25 @@ class TestCompareRevisionsWithNonModelField(TestCase, WagtailTestUtils):
         # Add child page of class with base_form_class override
         # non model field is 'code'
         self.test_page = FormClassAdditionalFieldPage(
-            title='A Statement',
-            slug='a-statement',
-            location='Early Morning Cafe, Mainland, NZ',
-            body="<p>hello</p>"
+            title="A Statement",
+            slug="a-statement",
+            location="Early Morning Cafe, Mainland, NZ",
+            body="<p>hello</p>",
         )
         self.root_page.add_child(instance=self.test_page)
 
         # add new revision
-        self.test_page.title = 'Statement'
-        self.test_page.location = 'Victory Monument, Bangkok'
-        self.test_page.body = (
-            "<p>I would like very much to go into the forrest.</p>"
-        )
+        self.test_page.title = "Statement"
+        self.test_page.location = "Victory Monument, Bangkok"
+        self.test_page.body = "<p>I would like very much to go into the forrest.</p>"
         self.test_page_revision = self.test_page.save_revision()
         self.test_page_revision.created_at = local_datetime(2017, 10, 15)
         self.test_page_revision.save()
 
         # add another new revision
-        self.test_page.title = 'True Statement'
-        self.test_page.location = 'Victory Monument, Bangkok'
-        self.test_page.body = (
-            "<p>I would like very much to go into the forest.</p>"
-        )
+        self.test_page.title = "True Statement"
+        self.test_page.location = "Victory Monument, Bangkok"
+        self.test_page.body = "<p>I would like very much to go into the forest.</p>"
         self.test_page_revision_new = self.test_page.save_revision()
         self.test_page_revision_new.created_at = local_datetime(2017, 10, 16)
         self.test_page_revision_new.save()
@@ -335,29 +340,43 @@ class TestCompareRevisionsWithNonModelField(TestCase, WagtailTestUtils):
 
     def test_base_form_class_used(self):
         """First ensure that the non-model field is appearing in edit."""
-        edit_url = reverse('wagtailadmin_pages:add', args=('tests', 'formclassadditionalfieldpage', self.test_page.id))
+        edit_url = reverse(
+            "wagtailadmin_pages:add",
+            args=("tests", "formclassadditionalfieldpage", self.test_page.id),
+        )
         response = self.client.get(edit_url)
-        self.assertContains(response, '<input type="text" name="code" required id="id_code" maxlength="5" />', html=True)
+        self.assertContains(
+            response,
+            '<input type="text" name="code" required id="id_code" maxlength="5" />',
+            html=True,
+        )
 
     def test_compare_revisions(self):
         """Confirm that the non-model field is not shown in revision."""
         compare_url = reverse(
-            'wagtailadmin_pages:revisions_compare',
-            args=(self.test_page.id, self.test_page_revision.id, self.test_page_revision_new.id)
+            "wagtailadmin_pages:revisions_compare",
+            args=(
+                self.test_page.id,
+                self.test_page_revision.id,
+                self.test_page_revision_new.id,
+            ),
         )
         response = self.client.get(compare_url)
-        self.assertContains(response, '<span class="deletion">forrest.</span><span class="addition">forest.</span>')
+        self.assertContains(
+            response,
+            '<span class="deletion">forrest.</span><span class="addition">forest.</span>',
+        )
         # should not contain the field defined in the formclass used
-        self.assertNotContains(response, '<h2>Code:</h2>')
+        self.assertNotContains(response, "<h2>Code:</h2>")
 
 
 class TestRevisionsUnschedule(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.christmas_event = EventPage.objects.get(url_path='/home/events/christmas/')
+        self.christmas_event = EventPage.objects.get(url_path="/home/events/christmas/")
         self.christmas_event.title = "Last Christmas"
-        self.christmas_event.date_from = '2013-12-25'
+        self.christmas_event.date_from = "2013-12-25"
         self.christmas_event.body = (
             "<p>Last Christmas I gave you my heart, "
             "but the very next day you gave it away</p>"
@@ -368,7 +387,7 @@ class TestRevisionsUnschedule(TestCase, WagtailTestUtils):
         self.last_christmas_revision.publish()
 
         self.christmas_event.title = "This Christmas"
-        self.christmas_event.date_from = '2014-12-25'
+        self.christmas_event.date_from = "2014-12-25"
         self.christmas_event.body = (
             "<p>This year, to save me from tears, "
             "I'll give it to someone special</p>"
@@ -386,16 +405,25 @@ class TestRevisionsUnschedule(TestCase, WagtailTestUtils):
         """
         This tests that the unschedule view responds with a confirm page
         """
-        response = self.client.get(reverse('wagtailadmin_pages:revisions_unschedule', args=(self.christmas_event.id, self.this_christmas_revision.id)))
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:revisions_unschedule",
+                args=(self.christmas_event.id, self.this_christmas_revision.id),
+            )
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/revisions/confirm_unschedule.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/revisions/confirm_unschedule.html"
+        )
 
     def test_unschedule_view_invalid_page_id(self):
         """
         This tests that the unschedule view returns an error if the page id is invalid
         """
         # Get unschedule page
-        response = self.client.get(reverse('wagtailadmin_pages:revisions_unschedule', args=(12345, 67894)))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:revisions_unschedule", args=(12345, 67894))
+        )
 
         # Check that the user received a 404 response
         self.assertEqual(response.status_code, 404)
@@ -405,7 +433,12 @@ class TestRevisionsUnschedule(TestCase, WagtailTestUtils):
         This tests that the unschedule view returns an error if the page id is invalid
         """
         # Get unschedule page
-        response = self.client.get(reverse('wagtailadmin_pages:revisions_unschedule', args=(self.christmas_event.id, 67894)))
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:revisions_unschedule",
+                args=(self.christmas_event.id, 67894),
+            )
+        )
 
         # Check that the user received a 404 response
         self.assertEqual(response.status_code, 404)
@@ -417,12 +450,19 @@ class TestRevisionsUnschedule(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         # Get unschedule page
-        response = self.client.get(reverse('wagtailadmin_pages:revisions_unschedule', args=(self.christmas_event.id, self.this_christmas_revision.id)))
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:revisions_unschedule",
+                args=(self.christmas_event.id, self.this_christmas_revision.id),
+            )
+        )
 
         # Check that the user received a 302 redirected response
         self.assertEqual(response.status_code, 302)
@@ -433,28 +473,42 @@ class TestRevisionsUnschedule(TestCase, WagtailTestUtils):
         """
 
         # Post to the unschedule page
-        response = self.client.post(reverse('wagtailadmin_pages:revisions_unschedule', args=(self.christmas_event.id, self.this_christmas_revision.id)))
+        response = self.client.post(
+            reverse(
+                "wagtailadmin_pages:revisions_unschedule",
+                args=(self.christmas_event.id, self.this_christmas_revision.id),
+            )
+        )
 
         # Should be redirected to page history
-        self.assertRedirects(response, reverse('wagtailadmin_pages:history', args=(self.christmas_event.id, )))
+        self.assertRedirects(
+            response,
+            reverse("wagtailadmin_pages:history", args=(self.christmas_event.id,)),
+        )
 
         # Check that the page has no approved_schedule
-        self.assertFalse(EventPage.objects.get(id=self.christmas_event.id).approved_schedule)
+        self.assertFalse(
+            EventPage.objects.get(id=self.christmas_event.id).approved_schedule
+        )
 
         # Check that the approved_go_live_at has been cleared from the revision
-        self.assertIsNone(self.christmas_event.revisions.get(id=self.this_christmas_revision.id).approved_go_live_at)
+        self.assertIsNone(
+            self.christmas_event.revisions.get(
+                id=self.this_christmas_revision.id
+            ).approved_go_live_at
+        )
 
 
 class TestRevisionsUnscheduleForUnpublishedPages(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
-        self.unpublished_event = EventPage.objects.get(url_path='/home/events/tentative-unpublished-event/')
-        self.unpublished_event.title = "Unpublished Page"
-        self.unpublished_event.date_from = '2014-12-25'
-        self.unpublished_event.body = (
-            "<p>Some Content</p>"
+        self.unpublished_event = EventPage.objects.get(
+            url_path="/home/events/tentative-unpublished-event/"
         )
+        self.unpublished_event.title = "Unpublished Page"
+        self.unpublished_event.date_from = "2014-12-25"
+        self.unpublished_event.body = "<p>Some Content</p>"
         self.unpublished_revision = self.unpublished_event.save_revision()
         self.unpublished_revision.created_at = local_datetime(2014, 12, 25)
         self.unpublished_revision.save()
@@ -465,9 +519,16 @@ class TestRevisionsUnscheduleForUnpublishedPages(TestCase, WagtailTestUtils):
         """
         This tests that the unschedule view responds with a confirm page
         """
-        response = self.client.get(reverse('wagtailadmin_pages:revisions_unschedule', args=(self.unpublished_event.id, self.unpublished_revision.id)))
+        response = self.client.get(
+            reverse(
+                "wagtailadmin_pages:revisions_unschedule",
+                args=(self.unpublished_event.id, self.unpublished_revision.id),
+            )
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/revisions/confirm_unschedule.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/revisions/confirm_unschedule.html"
+        )
 
     def test_unschedule_view_post(self):
         """
@@ -475,13 +536,27 @@ class TestRevisionsUnscheduleForUnpublishedPages(TestCase, WagtailTestUtils):
         """
 
         # Post to the unschedule page
-        response = self.client.post(reverse('wagtailadmin_pages:revisions_unschedule', args=(self.unpublished_event.id, self.unpublished_revision.id)))
+        response = self.client.post(
+            reverse(
+                "wagtailadmin_pages:revisions_unschedule",
+                args=(self.unpublished_event.id, self.unpublished_revision.id),
+            )
+        )
 
         # Should be redirected to page history
-        self.assertRedirects(response, reverse('wagtailadmin_pages:history', args=(self.unpublished_event.id, )))
+        self.assertRedirects(
+            response,
+            reverse("wagtailadmin_pages:history", args=(self.unpublished_event.id,)),
+        )
 
         # Check that the page has no approved_schedule
-        self.assertFalse(EventPage.objects.get(id=self.unpublished_event.id).approved_schedule)
+        self.assertFalse(
+            EventPage.objects.get(id=self.unpublished_event.id).approved_schedule
+        )
 
         # Check that the approved_go_live_at has been cleared from the revision
-        self.assertIsNone(self.unpublished_event.revisions.get(id=self.unpublished_revision.id).approved_go_live_at)
+        self.assertIsNone(
+            self.unpublished_event.revisions.get(
+                id=self.unpublished_revision.id
+            ).approved_go_live_at
+        )

+ 86 - 47
wagtail/admin/tests/pages/test_unpublish_page.py

@@ -20,7 +20,7 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
         self.root_page = Page.objects.get(id=2)
         self.page = SimplePage(
             title="Hello world!",
-            slug='hello-world',
+            slug="hello-world",
             content="hello",
             live=True,
         )
@@ -31,18 +31,22 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
         This tests that the unpublish view responds with an unpublish confirm page
         """
         # Get unpublish page
-        response = self.client.get(reverse('wagtailadmin_pages:unpublish', args=(self.page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:unpublish", args=(self.page.id,))
+        )
 
         # Check that the user received an unpublish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/confirm_unpublish.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/confirm_unpublish.html")
 
     def test_unpublish_view_invalid_page_id(self):
         """
         This tests that the unpublish view returns an error if the page id is invalid
         """
         # Get unpublish page
-        response = self.client.get(reverse('wagtailadmin_pages:unpublish', args=(12345, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:unpublish", args=(12345,))
+        )
 
         # Check that the user received a 404 response
         self.assertEqual(response.status_code, 404)
@@ -54,12 +58,16 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         # Get unpublish page
-        response = self.client.get(reverse('wagtailadmin_pages:unpublish', args=(self.page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:unpublish", args=(self.page.id,))
+        )
 
         # Check that the user received a 302 redirected response
         self.assertEqual(response.status_code, 302)
@@ -73,10 +81,14 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
         page_unpublished.connect(mock_handler)
 
         # Post to the unpublish page
-        response = self.client.post(reverse('wagtailadmin_pages:unpublish', args=(self.page.id, )))
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unpublish", args=(self.page.id,))
+        )
 
         # Should be redirected to explorer page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page was unpublished
         self.assertFalse(SimplePage.objects.get(id=self.page.id).live)
@@ -85,9 +97,9 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
         self.assertEqual(mock_handler.call_count, 1)
         mock_call = mock_handler.mock_calls[0][2]
 
-        self.assertEqual(mock_call['sender'], self.page.specific_class)
-        self.assertEqual(mock_call['instance'], self.page)
-        self.assertIsInstance(mock_call['instance'], self.page.specific_class)
+        self.assertEqual(mock_call["sender"], self.page.specific_class)
+        self.assertEqual(mock_call["instance"], self.page)
+        self.assertIsInstance(mock_call["instance"], self.page.specific_class)
 
     def test_after_unpublish_page(self):
         def hook_func(request, page):
@@ -96,10 +108,10 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('after_unpublish_page', hook_func):
+        with self.register_hook("after_unpublish_page", hook_func):
             post_data = {}
             response = self.client.post(
-                reverse('wagtailadmin_pages:unpublish', args=(self.page.id, )), post_data
+                reverse("wagtailadmin_pages:unpublish", args=(self.page.id,)), post_data
             )
 
         self.assertEqual(response.status_code, 200)
@@ -115,10 +127,10 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
 
             return HttpResponse("Overridden!")
 
-        with self.register_hook('before_unpublish_page', hook_func):
+        with self.register_hook("before_unpublish_page", hook_func):
             post_data = {}
             response = self.client.post(
-                reverse('wagtailadmin_pages:unpublish', args=(self.page.id, )), post_data
+                reverse("wagtailadmin_pages:unpublish", args=(self.page.id,)), post_data
             )
 
         self.assertEqual(response.status_code, 200)
@@ -133,13 +145,18 @@ class TestPageUnpublish(TestCase, WagtailTestUtils):
         This tests that the unpublish view responds with an unpublish confirm page that does not contain the form field 'include_descendants'
         """
         # Get unpublish page
-        response = self.client.get(reverse('wagtailadmin_pages:unpublish', args=(self.page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:unpublish", args=(self.page.id,))
+        )
 
         # Check that the user received an unpublish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/confirm_unpublish.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/confirm_unpublish.html")
         # Check the form does not contain the checkbox field include_descendants
-        self.assertNotContains(response, '<input id="id_include_descendants" name="include_descendants" type="checkbox">')
+        self.assertNotContains(
+            response,
+            '<input id="id_include_descendants" name="include_descendants" type="checkbox">',
+        )
 
 
 class TestPageUnpublishIncludingDescendants(TestCase, WagtailTestUtils):
@@ -149,70 +166,92 @@ class TestPageUnpublishIncludingDescendants(TestCase, WagtailTestUtils):
         self.root_page = Page.objects.get(id=2)
 
         # Create a page to unpublish
-        self.test_page = self.root_page.add_child(instance=SimplePage(
-            title="Hello world!",
-            slug='hello-world',
-            content="hello",
-            live=True,
-            has_unpublished_changes=False,
-        ))
+        self.test_page = self.root_page.add_child(
+            instance=SimplePage(
+                title="Hello world!",
+                slug="hello-world",
+                content="hello",
+                live=True,
+                has_unpublished_changes=False,
+            )
+        )
 
         # Create a couple of child pages
-        self.test_child_page = self.test_page.add_child(instance=SimplePage(
-            title="Child page",
-            slug='child-page',
-            content="hello",
-            live=True,
-            has_unpublished_changes=True,
-        ))
+        self.test_child_page = self.test_page.add_child(
+            instance=SimplePage(
+                title="Child page",
+                slug="child-page",
+                content="hello",
+                live=True,
+                has_unpublished_changes=True,
+            )
+        )
 
-        self.test_another_child_page = self.test_page.add_child(instance=SimplePage(
-            title="Another Child page",
-            slug='another-child-page',
-            content="hello",
-            live=True,
-            has_unpublished_changes=True,
-        ))
+        self.test_another_child_page = self.test_page.add_child(
+            instance=SimplePage(
+                title="Another Child page",
+                slug="another-child-page",
+                content="hello",
+                live=True,
+                has_unpublished_changes=True,
+            )
+        )
 
     def test_unpublish_descendants_view(self):
         """
         This tests that the unpublish view responds with an unpublish confirm page that contains the form field 'include_descendants'
         """
         # Get unpublish page
-        response = self.client.get(reverse('wagtailadmin_pages:unpublish', args=(self.test_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:unpublish", args=(self.test_page.id,))
+        )
 
         # Check that the user received an unpublish confirm page
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/confirm_unpublish.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/confirm_unpublish.html")
         # Check the form contains the checkbox field include_descendants
-        self.assertContains(response, '<input id="id_include_descendants" name="include_descendants" type="checkbox">')
+        self.assertContains(
+            response,
+            '<input id="id_include_descendants" name="include_descendants" type="checkbox">',
+        )
 
     def test_unpublish_include_children_view_post(self):
         """
         This posts to the unpublish view and checks that the page and its descendants were unpublished
         """
         # Post to the unpublish page
-        response = self.client.post(reverse('wagtailadmin_pages:unpublish', args=(self.test_page.id, )), {'include_descendants': 'on'})
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unpublish", args=(self.test_page.id,)),
+            {"include_descendants": "on"},
+        )
 
         # Should be redirected to explorer page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page was unpublished
         self.assertFalse(SimplePage.objects.get(id=self.test_page.id).live)
 
         # Check that the descendant pages were unpublished as well
         self.assertFalse(SimplePage.objects.get(id=self.test_child_page.id).live)
-        self.assertFalse(SimplePage.objects.get(id=self.test_another_child_page.id).live)
+        self.assertFalse(
+            SimplePage.objects.get(id=self.test_another_child_page.id).live
+        )
 
     def test_unpublish_not_include_children_view_post(self):
         """
         This posts to the unpublish view and checks that the page was unpublished but its descendants were not
         """
         # Post to the unpublish page
-        response = self.client.post(reverse('wagtailadmin_pages:unpublish', args=(self.test_page.id, )), {})
+        response = self.client.post(
+            reverse("wagtailadmin_pages:unpublish", args=(self.test_page.id,)), {}
+        )
 
         # Should be redirected to explorer page
-        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        self.assertRedirects(
+            response, reverse("wagtailadmin_explore", args=(self.root_page.id,))
+        )
 
         # Check that the page was unpublished
         self.assertFalse(SimplePage.objects.get(id=self.test_page.id).live)

+ 24 - 14
wagtail/admin/tests/pages/test_view_draft.py

@@ -24,13 +24,15 @@ class TestDraftAccess(TestCase, WagtailTestUtils):
         self.root_page.add_child(instance=self.child_page)
 
         # Add stream page (which has empty preview_modes, and so doesn't allow viewing draft)
-        self.stream_page = StreamPage(title='stream page', body=[('text', 'hello')])
+        self.stream_page = StreamPage(title="stream page", body=[("text", "hello")])
         self.root_page.add_child(instance=self.stream_page)
 
         # create user with admin access (but not draft_view access)
-        user = self.create_user(username='bob', password='password')
+        user = self.create_user(username="bob", password="password")
         user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
 
     def test_draft_access_admin(self):
@@ -39,7 +41,9 @@ class TestDraftAccess(TestCase, WagtailTestUtils):
         self.user = self.login()
 
         # Try getting page draft
-        response = self.client.get(reverse('wagtailadmin_pages:view_draft', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:view_draft", args=(self.child_page.id,))
+        )
 
         # User can view
         self.assertEqual(response.status_code, 200)
@@ -49,17 +53,21 @@ class TestDraftAccess(TestCase, WagtailTestUtils):
         self.user = self.login()
 
         # Try getting page draft
-        response = self.client.get(reverse('wagtailadmin_pages:view_draft', args=(self.stream_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:view_draft", args=(self.stream_page.id,))
+        )
 
         # Unauthorized response (because this page type has previewing disabled)
-        self.assertRedirects(response, '/admin/')
+        self.assertRedirects(response, "/admin/")
 
     def test_draft_access_unauthorized(self):
         """Test that user without edit/publish permission can't view draft."""
-        self.login(username='bob', password='password')
+        self.login(username="bob", password="password")
 
         # Try getting page draft
-        response = self.client.get(reverse('wagtailadmin_pages:view_draft', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:view_draft", args=(self.child_page.id,))
+        )
 
         # User gets redirected to the home page
         self.assertEqual(response.status_code, 302)
@@ -67,14 +75,16 @@ class TestDraftAccess(TestCase, WagtailTestUtils):
     def test_draft_access_authorized(self):
         """Test that user with edit permission can view draft."""
         # give user the permission to edit page
-        user = get_user_model().objects.get(email='bob@example.com')
-        user.groups.add(Group.objects.get(name='Moderators'))
+        user = get_user_model().objects.get(email="bob@example.com")
+        user.groups.add(Group.objects.get(name="Moderators"))
         user.save()
 
-        self.login(username='bob', password='password')
+        self.login(username="bob", password="password")
 
         # Get add subpage page
-        response = self.client.get(reverse('wagtailadmin_pages:view_draft', args=(self.child_page.id, )))
+        response = self.client.get(
+            reverse("wagtailadmin_pages:view_draft", args=(self.child_page.id,))
+        )
 
         # User can view
         self.assertEqual(response.status_code, 200)
@@ -86,7 +96,7 @@ class TestDraftAccess(TestCase, WagtailTestUtils):
         """
         self.login()
         response = self.client.get(
-            reverse('wagtailadmin_pages:view_draft', args=(self.child_page.id, )),
-            HTTP_USER_AGENT='EvilHacker'
+            reverse("wagtailadmin_pages:view_draft", args=(self.child_page.id,)),
+            HTTP_USER_AGENT="EvilHacker",
         )
         self.assertEqual(response.status_code, 403)

+ 41 - 12
wagtail/admin/tests/pages/test_workflow_history.py

@@ -7,13 +7,13 @@ from wagtail.tests.utils import WagtailTestUtils
 
 
 class TestWorkflowHistoryDetail(TestCase, WagtailTestUtils):
-    fixtures = ['test.json']
+    fixtures = ["test.json"]
 
     def setUp(self):
         self.user = self.create_test_user()
         self.login(self.user)
 
-        self.christmas_event = Page.objects.get(url_path='/home/events/christmas/')
+        self.christmas_event = Page.objects.get(url_path="/home/events/christmas/")
         self.christmas_event.save_revision()
 
         workflow = self.christmas_event.get_workflow()
@@ -21,46 +21,75 @@ class TestWorkflowHistoryDetail(TestCase, WagtailTestUtils):
 
     def test_get_index(self):
         response = self.client.get(
-            reverse('wagtailadmin_pages:workflow_history', args=[self.christmas_event.id])
+            reverse(
+                "wagtailadmin_pages:workflow_history", args=[self.christmas_event.id]
+            )
         )
         self.assertEqual(response.status_code, 200)
 
-        self.assertContains(response, reverse('wagtailadmin_pages:edit', args=[self.christmas_event.id]))
-        self.assertContains(response, reverse('wagtailadmin_pages:workflow_history_detail', args=[self.christmas_event.id, self.workflow_state.id]))
+        self.assertContains(
+            response, reverse("wagtailadmin_pages:edit", args=[self.christmas_event.id])
+        )
+        self.assertContains(
+            response,
+            reverse(
+                "wagtailadmin_pages:workflow_history_detail",
+                args=[self.christmas_event.id, self.workflow_state.id],
+            ),
+        )
 
     def test_get_index_with_bad_permissions(self):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         response = self.client.get(
-            reverse('wagtailadmin_pages:workflow_history', args=[self.christmas_event.id])
+            reverse(
+                "wagtailadmin_pages:workflow_history", args=[self.christmas_event.id]
+            )
         )
 
         self.assertEqual(response.status_code, 302)
 
     def test_get_detail(self):
         response = self.client.get(
-            reverse('wagtailadmin_pages:workflow_history_detail', args=[self.christmas_event.id, self.workflow_state.id])
+            reverse(
+                "wagtailadmin_pages:workflow_history_detail",
+                args=[self.christmas_event.id, self.workflow_state.id],
+            )
         )
         self.assertEqual(response.status_code, 200)
 
-        self.assertContains(response, reverse('wagtailadmin_pages:edit', args=[self.christmas_event.id]))
-        self.assertContains(response, reverse('wagtailadmin_pages:workflow_history', args=[self.christmas_event.id]))
+        self.assertContains(
+            response, reverse("wagtailadmin_pages:edit", args=[self.christmas_event.id])
+        )
+        self.assertContains(
+            response,
+            reverse(
+                "wagtailadmin_pages:workflow_history", args=[self.christmas_event.id]
+            ),
+        )
 
     def test_get_detail_with_bad_permissions(self):
         # Remove privileges from user
         self.user.is_superuser = False
         self.user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         self.user.save()
 
         response = self.client.get(
-            reverse('wagtailadmin_pages:workflow_history_detail', args=[self.christmas_event.id, self.workflow_state.id])
+            reverse(
+                "wagtailadmin_pages:workflow_history_detail",
+                args=[self.christmas_event.id, self.workflow_state.id],
+            )
         )
 
         self.assertEqual(response.status_code, 302)

Plik diff jest za duży
+ 311 - 209
wagtail/admin/tests/test_account_management.py


+ 45 - 30
wagtail/admin/tests/test_admin_search.py

@@ -13,17 +13,17 @@ from wagtail.tests.utils import WagtailTestUtils
 class BaseSearchAreaTestCase(WagtailTestUtils, TestCase):
     rf = RequestFactory()
 
-    def search_other(self, current_url='/admin/', data=None):
+    def search_other(self, current_url="/admin/", data=None):
         request = self.rf.get(current_url, data=data)
         request.user = self.user
         template = Template("{% load wagtailadmin_tags %}{% search_other %}")
-        return template.render(Context({'request': request}))
+        return template.render(Context({"request": request}))
 
-    def menu_search(self, current_url='/admin/', data=None):
+    def menu_search(self, current_url="/admin/", data=None):
         request = self.rf.get(current_url, data=data)
         request.user = self.user
         template = Template("{% load wagtailadmin_tags %}{% menu_search %}")
-        return template.render(Context({'request': request}))
+        return template.render(Context({"request": request}))
 
 
 class TestSearchAreas(BaseSearchAreaTestCase):
@@ -32,38 +32,47 @@ class TestSearchAreas(BaseSearchAreaTestCase):
         self.user = self.login()
 
     def test_other_searches(self):
-        search_url = reverse('wagtailadmin_pages:search')
+        search_url = reverse("wagtailadmin_pages:search")
         query = "Hello"
         base_css = "search--custom-class"
         icon = '<svg class="icon icon-custom filter-options__icon" aria-hidden="true" focusable="false"><use href="#icon-custom"></use></svg>'
-        test_string = '<a href="/customsearch/?q=%s" class="%s" is-custom="true">%sMy Search</a>'
+        test_string = (
+            '<a href="/customsearch/?q=%s" class="%s" is-custom="true">%sMy Search</a>'
+        )
         # Testing the option link exists
-        response = self.client.get(search_url, {'q': query})
+        response = self.client.get(search_url, {"q": query})
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/search.html')
-        self.assertTemplateUsed(response, 'wagtailadmin/shared/search_area.html')
-        self.assertTemplateUsed(response, 'wagtailadmin/shared/search_other.html')
+        self.assertTemplateUsed(response, "wagtailadmin/pages/search.html")
+        self.assertTemplateUsed(response, "wagtailadmin/shared/search_area.html")
+        self.assertTemplateUsed(response, "wagtailadmin/shared/search_other.html")
         self.assertContains(response, test_string % (query, base_css, icon), html=True)
 
         # Testing is_shown
-        response = self.client.get(search_url, {'q': query, 'hide-option': "true"})
-        self.assertNotContains(response, test_string % (query, base_css, icon), status_code=200, html=True)
+        response = self.client.get(search_url, {"q": query, "hide-option": "true"})
+        self.assertNotContains(
+            response, test_string % (query, base_css, icon), status_code=200, html=True
+        )
 
         # Testing is_active
-        response = self.client.get(search_url, {'q': query, 'active-option': "true"})
-        self.assertContains(response, test_string % (query, base_css + " nolink", icon), status_code=200, html=True)
+        response = self.client.get(search_url, {"q": query, "active-option": "true"})
+        self.assertContains(
+            response,
+            test_string % (query, base_css + " nolink", icon),
+            status_code=200,
+            html=True,
+        )
 
     def test_menu_search(self):
         rendered = self.menu_search()
-        self.assertIn(reverse('wagtailadmin_pages:search'), rendered)
+        self.assertIn(reverse("wagtailadmin_pages:search"), rendered)
 
     def test_search_other(self):
         rendered = self.search_other()
-        self.assertIn(reverse('wagtailadmin_pages:search'), rendered)
-        self.assertIn('/customsearch/', rendered)
+        self.assertIn(reverse("wagtailadmin_pages:search"), rendered)
+        self.assertIn("/customsearch/", rendered)
 
-        self.assertIn('Pages', rendered)
-        self.assertIn('My Search', rendered)
+        self.assertIn("Pages", rendered)
+        self.assertIn("My Search", rendered)
 
 
 class TestSearchAreaNoPagePermissions(BaseSearchAreaTestCase):
@@ -71,6 +80,7 @@ class TestSearchAreaNoPagePermissions(BaseSearchAreaTestCase):
     Test the admin search when the user does not have permission to manage
     pages. The search bar should show the first available search area instead.
     """
+
     def setUp(self):
         self.user = self.login()
         self.assertFalse(user_has_any_page_permission(self.user))
@@ -79,7 +89,9 @@ class TestSearchAreaNoPagePermissions(BaseSearchAreaTestCase):
         user = super().create_test_user()
         user.is_superuser = False
         user.user_permissions.add(
-            Permission.objects.get(content_type__app_label='wagtailadmin', codename='access_admin')
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
         )
         user.save()
         return user
@@ -89,28 +101,31 @@ class TestSearchAreaNoPagePermissions(BaseSearchAreaTestCase):
         Check that the menu search area on the dashboard is not searching
         pages, as they are not allowed.
         """
-        response = self.client.get('/admin/')
+        response = self.client.get("/admin/")
         # The menu search bar should go to /customsearch/, not /admin/pages/search/
-        self.assertNotContains(response, reverse('wagtailadmin_pages:search'))
-        self.assertContains(response, '{"_type": "wagtail.sidebar.SearchModule", "_args": ["/customsearch/"]}')
+        self.assertNotContains(response, reverse("wagtailadmin_pages:search"))
+        self.assertContains(
+            response,
+            '{"_type": "wagtail.sidebar.SearchModule", "_args": ["/customsearch/"]}',
+        )
 
     def test_menu_search(self):
         """
         The search form should go to the custom search, not the page search.
         """
         rendered = self.menu_search()
-        self.assertNotIn(reverse('wagtailadmin_pages:search'), rendered)
+        self.assertNotIn(reverse("wagtailadmin_pages:search"), rendered)
         self.assertIn('action="/customsearch/"', rendered)
 
     def test_search_other(self):
         """The pages search link should be hidden, custom search should be visible."""
         rendered = self.search_other()
-        self.assertNotIn(reverse('wagtailadmin_pages:search'), rendered)
-        self.assertIn('/customsearch/', rendered)
+        self.assertNotIn(reverse("wagtailadmin_pages:search"), rendered)
+        self.assertIn("/customsearch/", rendered)
 
-        self.assertNotIn('Pages', rendered)
-        self.assertIn('My Search', rendered)
+        self.assertNotIn("Pages", rendered)
+        self.assertIn("My Search", rendered)
 
     def test_no_searches(self):
-        rendered = self.menu_search(data={'hide-option': 'true'})
-        self.assertEqual(rendered, '')
+        rendered = self.menu_search(data={"hide-option": "true"})
+        self.assertEqual(rendered, "")

+ 92 - 54
wagtail/admin/tests/test_audit_log.py

@@ -6,7 +6,12 @@ from django.urls import reverse
 from django.utils import timezone
 from freezegun import freeze_time
 
-from wagtail.core.models import GroupPagePermission, Page, PageLogEntry, PageViewRestriction
+from wagtail.core.models import (
+    GroupPagePermission,
+    Page,
+    PageLogEntry,
+    PageViewRestriction,
+)
 from wagtail.tests.testapp.models import SimplePage
 from wagtail.tests.utils import WagtailTestUtils
 
@@ -17,7 +22,7 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
 
         self.hello_page = SimplePage(
             title="Hello world!",
-            slug='hello-world',
+            slug="hello-world",
             content="hello",
             live=False,
         )
@@ -27,19 +32,22 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
         self.root_page.add_child(instance=self.about_page)
 
         self.administrator = self.create_superuser(
-            username='administrator',
-            email='administrator@email.com',
-            password='password'
-        )
-        self.editor = self.create_user(username='the_editor', email='the_editor@example.com', password='password')
-        sub_editors = Group.objects.create(name='Sub editors')
-        sub_editors.permissions.add(Permission.objects.get(
-            content_type__app_label='wagtailadmin',
-            codename='access_admin'
-        ))
+            username="administrator",
+            email="administrator@email.com",
+            password="password",
+        )
+        self.editor = self.create_user(
+            username="the_editor", email="the_editor@example.com", password="password"
+        )
+        sub_editors = Group.objects.create(name="Sub editors")
+        sub_editors.permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
+        )
         self.editor.groups.add(sub_editors)
 
-        for permission_type in ['add', 'edit', 'publish']:
+        for permission_type in ["add", "edit", "publish"]:
             GroupPagePermission.objects.create(
                 group=sub_editors, page=self.hello_page, permission_type=permission_type
             )
@@ -57,11 +65,13 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
             revision.publish(user=self.editor)
 
             # lock/unlock
-            page.save(user=self.editor, log_action='wagtail.lock')
-            page.save(user=self.editor, log_action='wagtail.unlock')
+            page.save(user=self.editor, log_action="wagtail.lock")
+            page.save(user=self.editor, log_action="wagtail.unlock")
 
             # change privacy
-            restriction = PageViewRestriction(page=page, restriction_type=PageViewRestriction.LOGIN)
+            restriction = PageViewRestriction(
+                page=page, restriction_type=PageViewRestriction.LOGIN
+            )
             restriction.save(user=self.editor)
             restriction.restriction_type = PageViewRestriction.PASSWORD
             restriction.save(user=self.administrator)
@@ -70,7 +80,9 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
     def test_page_history(self):
         self._update_page(self.hello_page)
 
-        history_url = reverse('wagtailadmin_pages:history', kwargs={'page_id': self.hello_page.id})
+        history_url = reverse(
+            "wagtailadmin_pages:history", kwargs={"page_id": self.hello_page.id}
+        )
 
         self.login(user=self.editor)
 
@@ -85,27 +97,36 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
         self.assertContains(response, "Published", 1)
 
         self.assertContains(
-            response, "Added the &#x27;Private, accessible to logged-in users&#x27; view restriction"
+            response,
+            "Added the &#x27;Private, accessible to logged-in users&#x27; view restriction",
         )
         self.assertContains(
             response,
-            "Updated the view restriction to &#x27;Private, accessible with the following password&#x27;"
+            "Updated the view restriction to &#x27;Private, accessible with the following password&#x27;",
         )
         self.assertContains(
             response,
-            "Removed the &#x27;Private, accessible with the following password&#x27; view restriction"
+            "Removed the &#x27;Private, accessible with the following password&#x27; view restriction",
         )
 
-        self.assertContains(response, 'system', 2)  # create without a user + remove restriction
-        self.assertContains(response, 'the_editor', 9)  # 7 entries by editor + 1 in sidebar menu + 1 in filter
-        self.assertContains(response, 'administrator', 2)  # the final restriction change + filter
+        self.assertContains(
+            response, "system", 2
+        )  # create without a user + remove restriction
+        self.assertContains(
+            response, "the_editor", 9
+        )  # 7 entries by editor + 1 in sidebar menu + 1 in filter
+        self.assertContains(
+            response, "administrator", 2
+        )  # the final restriction change + filter
 
     def test_page_history_filters(self):
         self.login(user=self.editor)
         self._update_page(self.hello_page)
 
-        history_url = reverse('wagtailadmin_pages:history', kwargs={'page_id': self.hello_page.id})
-        response = self.client.get(history_url + '?action=wagtail.edit')
+        history_url = reverse(
+            "wagtailadmin_pages:history", kwargs={"page_id": self.hello_page.id}
+        )
+        response = self.client.get(history_url + "?action=wagtail.edit")
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "Draft saved", count=2)
         self.assertNotContains(response, "Locked")
@@ -118,7 +139,7 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
         self.about_page.save_revision(user=self.administrator, log_action=True)
         self.about_page.delete(user=self.administrator)
 
-        site_history_url = reverse('wagtailadmin_reports:site_history')
+        site_history_url = reverse("wagtailadmin_reports:site_history")
 
         # the editor has access to the root page, so should see everything
         self.login(user=self.editor)
@@ -126,9 +147,9 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
         response = self.client.get(site_history_url)
         self.assertEqual(response.status_code, 200)
 
-        self.assertNotContains(response, 'About')
+        self.assertNotContains(response, "About")
         self.assertContains(response, "Draft saved", 2)
-        self.assertNotContains(response, 'Deleted')
+        self.assertNotContains(response, "Deleted")
 
         # once a page is deleted, its log entries are only visible to super admins or users with
         # permissions on the root page
@@ -137,19 +158,19 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
         self.assertContains(response, "No log entries found")
 
         # add the editor user to the Editors group which has permissions on the root page
-        self.editor.groups.add(Group.objects.get(name='Editors'))
+        self.editor.groups.add(Group.objects.get(name="Editors"))
         response = self.client.get(site_history_url)
 
-        self.assertContains(response, 'About', 3)  # create, save draft, delete
-        self.assertContains(response, 'Created', 2)
-        self.assertContains(response, 'Deleted', 2)
+        self.assertContains(response, "About", 3)  # create, save draft, delete
+        self.assertContains(response, "Created", 2)
+        self.assertContains(response, "Deleted", 2)
 
         # check with super admin
         self.login(user=self.administrator)
         response = self.client.get(site_history_url)
 
-        self.assertContains(response, 'About', 3)  # create, save draft, delete
-        self.assertContains(response, 'Deleted', 2)
+        self.assertContains(response, "About", 3)  # create, save draft, delete
+        self.assertContains(response, "Deleted", 2)
 
     def test_history_with_deleted_user(self):
         self._update_page(self.hello_page)
@@ -161,43 +182,55 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
 
         # check page history
         response = self.client.get(
-            reverse('wagtailadmin_pages:history', kwargs={'page_id': self.hello_page.id})
+            reverse(
+                "wagtailadmin_pages:history", kwargs={"page_id": self.hello_page.id}
+            )
         )
         self.assertContains(response, expected_deleted_string)
 
         # check site history
-        response = self.client.get(reverse('wagtailadmin_reports:site_history'))
+        response = self.client.get(reverse("wagtailadmin_reports:site_history"))
         self.assertContains(response, expected_deleted_string)
 
     def test_edit_form_has_history_link(self):
         self.hello_page.save_revision()
         self.login(user=self.editor)
         response = self.client.get(
-            reverse('wagtailadmin_pages:edit', args=[self.hello_page.id])
+            reverse("wagtailadmin_pages:edit", args=[self.hello_page.id])
         )
         self.assertEqual(response.status_code, 200)
-        history_url = reverse('wagtailadmin_pages:history', args=[self.hello_page.id])
+        history_url = reverse("wagtailadmin_pages:history", args=[self.hello_page.id])
         self.assertContains(response, history_url)
 
     def test_create_and_publish_does_not_log_revision_save(self):
         self.login(user=self.administrator)
         post_data = {
-            'title': "New page!",
-            'content': "Some content",
-            'slug': 'hello-world-redux',
-            'action-publish': 'action-publish',
+            "title": "New page!",
+            "content": "Some content",
+            "slug": "hello-world-redux",
+            "action-publish": "action-publish",
         }
         response = self.client.post(
-            reverse('wagtailadmin_pages:add', args=('tests', 'simplepage', self.root_page.id)),
-            post_data, follow=True
+            reverse(
+                "wagtailadmin_pages:add",
+                args=("tests", "simplepage", self.root_page.id),
+            ),
+            post_data,
+            follow=True,
         )
         self.assertEqual(response.status_code, 200)
 
-        page_id = Page.objects.get(path__startswith=self.root_page.path, slug='hello-world-redux').id
+        page_id = Page.objects.get(
+            path__startswith=self.root_page.path, slug="hello-world-redux"
+        ).id
 
         self.assertListEqual(
-            list(PageLogEntry.objects.filter(page=page_id).values_list('action', flat=True)),
-            ['wagtail.publish', 'wagtail.create']
+            list(
+                PageLogEntry.objects.filter(page=page_id).values_list(
+                    "action", flat=True
+                )
+            ),
+            ["wagtail.publish", "wagtail.create"],
         )
 
     def test_revert_and_publish_logs_reversion_and_publish(self):
@@ -206,17 +239,22 @@ class TestAuditLogAdmin(TestCase, WagtailTestUtils):
 
         self.login(user=self.administrator)
         response = self.client.post(
-            reverse('wagtailadmin_pages:edit', args=(self.hello_page.id, )),
+            reverse("wagtailadmin_pages:edit", args=(self.hello_page.id,)),
             {
-                'title': "Hello World!",
-                'content': "another hello",
-                'slug': 'hello-world',
-                'revision': revision.id, 'action-publish': 'action-publish'}, follow=True
+                "title": "Hello World!",
+                "content": "another hello",
+                "slug": "hello-world",
+                "revision": revision.id,
+                "action-publish": "action-publish",
+            },
+            follow=True,
         )
         self.assertEqual(response.status_code, 200)
 
-        entries = PageLogEntry.objects.filter(page=self.hello_page).values_list('action', flat=True)
+        entries = PageLogEntry.objects.filter(page=self.hello_page).values_list(
+            "action", flat=True
+        )
         self.assertListEqual(
             list(entries),
-            ['wagtail.publish', 'wagtail.rename', 'wagtail.revert', 'wagtail.create']
+            ["wagtail.publish", "wagtail.rename", "wagtail.revert", "wagtail.create"],
         )

+ 73 - 51
wagtail/admin/tests/test_buttons_hooks.py

@@ -34,115 +34,137 @@ class TestButtonsHooks(TestCase, WagtailTestUtils):
     def test_register_page_listing_buttons(self):
         def page_listing_buttons(page, page_perms, is_parent=False, next_url=None):
             yield wagtailadmin_widgets.PageListingButton(
-                'Another useless page listing button',
-                '/custom-url',
-                priority=10
+                "Another useless page listing button", "/custom-url", priority=10
             )
 
-        with hooks.register_temporarily('register_page_listing_buttons', page_listing_buttons):
+        with hooks.register_temporarily(
+            "register_page_listing_buttons", page_listing_buttons
+        ):
             response = self.client.get(
-                reverse('wagtailadmin_explore', args=(self.root_page.id, ))
+                reverse("wagtailadmin_explore", args=(self.root_page.id,))
             )
 
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/listing/_button_with_dropdown.html')
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/listing/_buttons.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/listing/_button_with_dropdown.html"
+        )
+        self.assertTemplateUsed(response, "wagtailadmin/pages/listing/_buttons.html")
 
-        self.assertContains(response, 'Another useless page listing button')
+        self.assertContains(response, "Another useless page listing button")
 
     def test_register_page_listing_more_buttons(self):
         def page_listing_more_buttons(page, page_perms, is_parent=False, next_url=None):
             yield wagtailadmin_widgets.Button(
                 'Another useless button in default "More" dropdown',
-                '/custom-url',
-                priority=10
+                "/custom-url",
+                priority=10,
             )
 
-        with hooks.register_temporarily('register_page_listing_more_buttons', page_listing_more_buttons):
+        with hooks.register_temporarily(
+            "register_page_listing_more_buttons", page_listing_more_buttons
+        ):
             response = self.client.get(
-                reverse('wagtailadmin_explore', args=(self.root_page.id, ))
+                reverse("wagtailadmin_explore", args=(self.root_page.id,))
             )
 
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/listing/_button_with_dropdown.html')
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/listing/_buttons.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/listing/_button_with_dropdown.html"
+        )
+        self.assertTemplateUsed(response, "wagtailadmin/pages/listing/_buttons.html")
 
-        self.assertContains(response, 'Another useless button in default &quot;More&quot; dropdown')
+        self.assertContains(
+            response, "Another useless button in default &quot;More&quot; dropdown"
+        )
 
     def test_custom_button_with_dropdown(self):
-        def page_custom_listing_buttons(page, page_perms, is_parent=False, next_url=None):
+        def page_custom_listing_buttons(
+            page, page_perms, is_parent=False, next_url=None
+        ):
             yield wagtailadmin_widgets.ButtonWithDropdownFromHook(
-                'One more more button',
-                hook_name='register_page_listing_one_more_more_buttons',
+                "One more more button",
+                hook_name="register_page_listing_one_more_more_buttons",
                 page=page,
                 page_perms=page_perms,
                 is_parent=is_parent,
                 next_url=next_url,
-                attrs={'target': '_blank', 'rel': 'noopener noreferrer'},
-                priority=50
+                attrs={"target": "_blank", "rel": "noopener noreferrer"},
+                priority=50,
             )
 
-        def page_custom_listing_more_buttons(page, page_perms, is_parent=False, next_url=None):
+        def page_custom_listing_more_buttons(
+            page, page_perms, is_parent=False, next_url=None
+        ):
             yield wagtailadmin_widgets.Button(
                 'Another useless dropdown button in "One more more button" dropdown',
-                '/custom-url',
-                priority=10
+                "/custom-url",
+                priority=10,
             )
 
-        with hooks.register_temporarily('register_page_listing_buttons', page_custom_listing_buttons), hooks.register_temporarily('register_page_listing_one_more_more_buttons', page_custom_listing_more_buttons):
+        with hooks.register_temporarily(
+            "register_page_listing_buttons", page_custom_listing_buttons
+        ), hooks.register_temporarily(
+            "register_page_listing_one_more_more_buttons",
+            page_custom_listing_more_buttons,
+        ):
             response = self.client.get(
-                reverse('wagtailadmin_explore', args=(self.root_page.id, ))
+                reverse("wagtailadmin_explore", args=(self.root_page.id,))
             )
 
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/listing/_button_with_dropdown.html')
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/listing/_buttons.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/listing/_button_with_dropdown.html"
+        )
+        self.assertTemplateUsed(response, "wagtailadmin/pages/listing/_buttons.html")
 
-        self.assertContains(response, 'One more more button')
-        self.assertContains(response, 'Another useless dropdown button in &quot;One more more button&quot; dropdown')
+        self.assertContains(response, "One more more button")
+        self.assertContains(
+            response,
+            "Another useless dropdown button in &quot;One more more button&quot; dropdown",
+        )
 
     def test_register_page_header_buttons(self):
         def page_header_buttons(page, page_perms, next_url=None):
             yield wagtailadmin_widgets.Button(
-                'Another useless header button',
-                '/custom-url',
-                priority=10
+                "Another useless header button", "/custom-url", priority=10
             )
 
-        with hooks.register_temporarily('register_page_header_buttons', page_header_buttons):
+        with hooks.register_temporarily(
+            "register_page_header_buttons", page_header_buttons
+        ):
             response = self.client.get(
-                reverse('wagtailadmin_pages:edit', args=(self.root_page.id, ))
+                reverse("wagtailadmin_pages:edit", args=(self.root_page.id,))
             )
 
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/pages/listing/_button_with_dropdown.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/pages/listing/_button_with_dropdown.html"
+        )
 
-        self.assertContains(response, 'Another useless header button')
+        self.assertContains(response, "Another useless header button")
 
     def test_delete_button_next_url(self):
         page_perms = PagePerms()
         page = self.root_page
-        base_url = reverse('wagtailadmin_pages:delete', args=[page.id])
+        base_url = reverse("wagtailadmin_pages:delete", args=[page.id])
 
         next_url = "a/random/url/"
-        full_url = base_url + '?' + urlencode({'next': next_url})
+        full_url = base_url + "?" + urlencode({"next": next_url})
 
         # page_listing_more_button generator yields only `Delete button`
-        delete_button = next(page_listing_more_buttons(
-            page,
-            page_perms,
-            is_parent=False,
-            next_url=next_url
-        ))
+        delete_button = next(
+            page_listing_more_buttons(
+                page, page_perms, is_parent=False, next_url=next_url
+            )
+        )
 
         self.assertEqual(delete_button.url, full_url)
 
-        next_url = reverse('wagtailadmin_explore', args=[page.id])
-        delete_button = next(page_listing_more_buttons(
-            page,
-            page_perms,
-            is_parent=False,
-            next_url=next_url
-        ))
+        next_url = reverse("wagtailadmin_explore", args=[page.id])
+        delete_button = next(
+            page_listing_more_buttons(
+                page, page_perms, is_parent=False, next_url=next_url
+            )
+        )
 
         self.assertEqual(delete_button.url, base_url)

+ 209 - 123
wagtail/admin/tests/test_collections_views.py

@@ -16,25 +16,29 @@ class CollectionInstanceTestUtils:
         """
         collection_content_type = ContentType.objects.get_for_model(Collection)
         self.add_permission = Permission.objects.get(
-            content_type=collection_content_type, codename='add_collection'
+            content_type=collection_content_type, codename="add_collection"
         )
         self.change_permission = Permission.objects.get(
-            content_type=collection_content_type, codename='change_collection'
+            content_type=collection_content_type, codename="change_collection"
         )
         self.delete_permission = Permission.objects.get(
-            content_type=collection_content_type, codename='delete_collection'
+            content_type=collection_content_type, codename="delete_collection"
         )
-        admin_permission = Permission.objects.get(codename='access_admin')
+        admin_permission = Permission.objects.get(codename="access_admin")
 
         self.root_collection = Collection.get_first_root_node()
         self.finance_collection = self.root_collection.add_child(name="Finance")
         self.marketing_collection = self.root_collection.add_child(name="Marketing")
-        self.marketing_sub_collection = self.marketing_collection.add_child(name="Digital Marketing")
-        self.marketing_sub_collection_2 = self.marketing_collection.add_child(name="Direct Mail Marketing")
+        self.marketing_sub_collection = self.marketing_collection.add_child(
+            name="Digital Marketing"
+        )
+        self.marketing_sub_collection_2 = self.marketing_collection.add_child(
+            name="Direct Mail Marketing"
+        )
 
         self.marketing_group = Group.objects.create(name="Marketing Group")
         self.marketing_group.permissions.add(admin_permission)
-        self.marketing_user = self.create_user('marketing', password='password')
+        self.marketing_user = self.create_user("marketing", password="password")
         self.marketing_user.groups.add(self.marketing_group)
 
 
@@ -43,12 +47,12 @@ class TestCollectionsIndexViewAsSuperuser(TestCase, WagtailTestUtils):
         self.login()
 
     def get(self, params={}):
-        return self.client.get(reverse('wagtailadmin_collections:index'), params)
+        return self.client.get(reverse("wagtailadmin_collections:index"), params)
 
     def test_simple(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/collections/index.html')
+        self.assertTemplateUsed(response, "wagtailadmin/collections/index.html")
 
         # Initially there should be no collections listed
         # (Root should not be shown)
@@ -60,7 +64,7 @@ class TestCollectionsIndexViewAsSuperuser(TestCase, WagtailTestUtils):
         # Now the listing should contain our collection
         response = self.get()
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/collections/index.html')
+        self.assertTemplateUsed(response, "wagtailadmin/collections/index.html")
         self.assertNotContains(response, "No collections have been created.")
         self.assertContains(response, "Holiday snaps")
 
@@ -72,8 +76,9 @@ class TestCollectionsIndexViewAsSuperuser(TestCase, WagtailTestUtils):
         response = self.get()
         # Note that the Collections have been automatically sorted by name.
         self.assertEqual(
-            [collection.name for collection in response.context['object_list']],
-            ['Avocado', 'Bread', 'Milk'])
+            [collection.name for collection in response.context["object_list"]],
+            ["Avocado", "Bread", "Milk"],
+        )
 
     def test_nested_ordering(self):
         root_collection = Collection.get_first_root_node()
@@ -91,36 +96,41 @@ class TestCollectionsIndexViewAsSuperuser(TestCase, WagtailTestUtils):
         # And we added the Collections at level 2 in reverse-alpha order as well, but they were also alphabetized
         # within their respective trees. This is the result of setting Collection.node_order_by = ['name'].
         self.assertEqual(
-            [collection.name for collection in response.context['object_list']],
-            ['Animal', 'Cat', 'Dog', 'Vegetable', 'Cucumber', 'Spinach'])
+            [collection.name for collection in response.context["object_list"]],
+            ["Animal", "Cat", "Dog", "Vegetable", "Cucumber", "Spinach"],
+        )
 
 
 class TestCollectionsIndexView(CollectionInstanceTestUtils, TestCase, WagtailTestUtils):
     def setUp(self):
         super().setUp()
-        self.login(self.marketing_user, password='password')
+        self.login(self.marketing_user, password="password")
 
     def get(self, params={}):
-        return self.client.get(reverse('wagtailadmin_collections:index'), params)
+        return self.client.get(reverse("wagtailadmin_collections:index"), params)
 
     def test_marketing_user_no_permissions(self):
         response = self.get()
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response.context['message'], 'Sorry, you do not have permission to access this area.')
+        self.assertEqual(
+            response.context["message"],
+            "Sorry, you do not have permission to access this area.",
+        )
 
     def test_marketing_user_with_change_permission(self):
         # Grant the marketing group permission to make changes to their collections
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.change_permission
+            permission=self.change_permission,
         )
 
         response = self.get()
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            [collection.name for collection in response.context['object_list']],
-            ['Marketing', 'Digital Marketing', 'Direct Mail Marketing'])
+            [collection.name for collection in response.context["object_list"]],
+            ["Marketing", "Digital Marketing", "Direct Mail Marketing"],
+        )
         self.assertNotContains(response, "Finance")
         self.assertNotContains(response, "Add a collection")
 
@@ -129,14 +139,15 @@ class TestCollectionsIndexView(CollectionInstanceTestUtils, TestCase, WagtailTes
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.add_permission
+            permission=self.add_permission,
         )
 
         response = self.get()
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            [collection.name for collection in response.context['object_list']],
-            ['Marketing', 'Digital Marketing', 'Direct Mail Marketing'])
+            [collection.name for collection in response.context["object_list"]],
+            ["Marketing", "Digital Marketing", "Direct Mail Marketing"],
+        )
         self.assertNotContains(response, "Finance")
         self.assertContains(response, "Add a collection")
 
@@ -145,14 +156,15 @@ class TestCollectionsIndexView(CollectionInstanceTestUtils, TestCase, WagtailTes
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.delete_permission
+            permission=self.delete_permission,
         )
 
         response = self.get()
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            [collection.name for collection in response.context['object_list']],
-            ['Marketing', 'Digital Marketing', 'Direct Mail Marketing'])
+            [collection.name for collection in response.context["object_list"]],
+            ["Marketing", "Digital Marketing", "Direct Mail Marketing"],
+        )
         self.assertNotContains(response, "Finance")
         self.assertNotContains(response, "Add a collection")
 
@@ -161,15 +173,16 @@ class TestCollectionsIndexView(CollectionInstanceTestUtils, TestCase, WagtailTes
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.root_collection,
-            permission=self.add_permission
+            permission=self.add_permission,
         )
 
         response = self.get()
         self.assertEqual(response.status_code, 200)
         # (Root should not be shown)
         self.assertEqual(
-            [collection.name for collection in response.context['object_list']],
-            ['Finance', 'Marketing', 'Digital Marketing', 'Direct Mail Marketing'])
+            [collection.name for collection in response.context["object_list"]],
+            ["Finance", "Marketing", "Digital Marketing", "Direct Mail Marketing"],
+        )
         self.assertContains(response, "Add a collection")
 
 
@@ -179,10 +192,10 @@ class TestAddCollectionAsSuperuser(TestCase, WagtailTestUtils):
         self.root_collection = Collection.get_first_root_node()
 
     def get(self, params={}):
-        return self.client.get(reverse('wagtailadmin_collections:add'), params)
+        return self.client.get(reverse("wagtailadmin_collections:add"), params)
 
     def post(self, post_data={}):
-        return self.client.post(reverse('wagtailadmin_collections:add'), post_data)
+        return self.client.post(reverse("wagtailadmin_collections:add"), post_data)
 
     def test_get(self):
         response = self.get()
@@ -190,59 +203,68 @@ class TestAddCollectionAsSuperuser(TestCase, WagtailTestUtils):
         self.assertContains(response, self.root_collection.name)
 
     def test_post(self):
-        response = self.post({
-            'name': "Holiday snaps",
-            'parent': self.root_collection.id,
-        })
+        response = self.post(
+            {
+                "name": "Holiday snaps",
+                "parent": self.root_collection.id,
+            }
+        )
 
         # Should redirect back to index
-        self.assertRedirects(response, reverse('wagtailadmin_collections:index'))
+        self.assertRedirects(response, reverse("wagtailadmin_collections:index"))
 
         # Check that the collection was created and is a child of root
         self.assertEqual(Collection.objects.filter(name="Holiday snaps").count(), 1)
         self.assertEqual(
             Collection.objects.get(name="Holiday snaps").get_parent(),
-            self.root_collection
+            self.root_collection,
         )
 
 
 class TestAddCollection(CollectionInstanceTestUtils, TestCase, WagtailTestUtils):
     def setUp(self):
         super().setUp()
-        self.login(self.marketing_user, password='password')
+        self.login(self.marketing_user, password="password")
 
     def get(self, params={}):
-        return self.client.get(reverse('wagtailadmin_collections:add'), params)
+        return self.client.get(reverse("wagtailadmin_collections:add"), params)
 
     def post(self, post_data={}):
-        return self.client.post(reverse('wagtailadmin_collections:add'), post_data)
+        return self.client.post(reverse("wagtailadmin_collections:add"), post_data)
 
     def test_marketing_user_no_permissions(self):
         response = self.get()
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response.context['message'], 'Sorry, you do not have permission to access this area.')
+        self.assertEqual(
+            response.context["message"],
+            "Sorry, you do not have permission to access this area.",
+        )
 
     def test_marketing_user_with_add_permission(self):
         # Grant the marketing group permission to manage their collection
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.add_permission
+            permission=self.add_permission,
         )
 
-        response = self.post({
-            'name': "Affiliate Marketing",
-            'parent': self.marketing_collection.id,
-        })
+        response = self.post(
+            {
+                "name": "Affiliate Marketing",
+                "parent": self.marketing_collection.id,
+            }
+        )
 
         # Should redirect back to index
-        self.assertRedirects(response, reverse('wagtailadmin_collections:index'))
+        self.assertRedirects(response, reverse("wagtailadmin_collections:index"))
 
         # Check that the collection was created and is a child of Marketing
-        self.assertEqual(Collection.objects.filter(name="Affiliate Marketing").count(), 1)
+        self.assertEqual(
+            Collection.objects.filter(name="Affiliate Marketing").count(), 1
+        )
         self.assertEqual(
             Collection.objects.get(name="Affiliate Marketing").get_parent(),
-            self.marketing_collection
+            self.marketing_collection,
         )
 
     def test_marketing_user_cannot_add_outside_their_hierarchy(self):
@@ -250,17 +272,19 @@ class TestAddCollection(CollectionInstanceTestUtils, TestCase, WagtailTestUtils)
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.add_permission
+            permission=self.add_permission,
         )
 
         # This user can't add to root collection
-        response = self.post({
-            'name': "Affiliate Marketing",
-            'parent': self.root_collection.id,
-        })
+        response = self.post(
+            {
+                "name": "Affiliate Marketing",
+                "parent": self.root_collection.id,
+            }
+        )
         self.assertEqual(
-            response.context['form'].errors['parent'],
-            ['Select a valid choice. That choice is not one of the available choices.']
+            response.context["form"].errors["parent"],
+            ["Select a valid choice. That choice is not one of the available choices."],
         )
 
 
@@ -275,14 +299,20 @@ class TestEditCollectionAsSuperuser(TestCase, WagtailTestUtils):
 
     def get(self, params={}, collection_id=None):
         return self.client.get(
-            reverse('wagtailadmin_collections:edit', args=(collection_id or self.collection.id,)),
-            params
+            reverse(
+                "wagtailadmin_collections:edit",
+                args=(collection_id or self.collection.id,),
+            ),
+            params,
         )
 
     def post(self, post_data={}, collection_id=None):
         return self.client.post(
-            reverse('wagtailadmin_collections:edit', args=(collection_id or self.collection.id,)),
-            post_data
+            reverse(
+                "wagtailadmin_collections:edit",
+                args=(collection_id or self.collection.id,),
+            ),
+            post_data,
         )
 
     def test_get(self):
@@ -295,7 +325,7 @@ class TestEditCollectionAsSuperuser(TestCase, WagtailTestUtils):
         self.assertEqual(response.status_code, 404)
 
     def test_admin_url_finder(self):
-        expected_url = '/admin/collections/%d/' % self.l2.pk
+        expected_url = "/admin/collections/%d/" % self.l2.pk
         url_finder = AdminURLFinder(self.user)
         self.assertEqual(url_finder.get_edit_url(self.l2), expected_url)
 
@@ -304,28 +334,30 @@ class TestEditCollectionAsSuperuser(TestCase, WagtailTestUtils):
         self.assertEqual(response.status_code, 404)
 
     def test_move_collection(self):
-        self.post({'name': "Level 2", 'parent': self.root_collection.pk}, self.l2.pk)
+        self.post({"name": "Level 2", "parent": self.root_collection.pk}, self.l2.pk)
         self.assertEqual(
             Collection.objects.get(pk=self.l2.pk).get_parent().pk,
             self.root_collection.pk,
         )
 
     def test_cannot_move_parent_collection_to_descendant(self):
-        response = self.post({'name': "Level 2", 'parent': self.l3.pk}, self.l2.pk)
-        self.assertEqual(response.context['message'], 'The collection could not be saved due to errors.')
-        self.assertContains(response, 'Please select another parent')
+        response = self.post({"name": "Level 2", "parent": self.l3.pk}, self.l2.pk)
+        self.assertEqual(
+            response.context["message"],
+            "The collection could not be saved due to errors.",
+        )
+        self.assertContains(response, "Please select another parent")
 
     def test_rename_collection(self):
-        data = {'name': "Skiing photos", 'parent': self.root_collection.id}
+        data = {"name": "Skiing photos", "parent": self.root_collection.id}
         response = self.post(data, self.collection.pk)
 
         # Should redirect back to index
-        self.assertRedirects(response, reverse('wagtailadmin_collections:index'))
+        self.assertRedirects(response, reverse("wagtailadmin_collections:index"))
 
         # Check that the collection was edited
         self.assertEqual(
-            Collection.objects.get(id=self.collection.id).name,
-            "Skiing photos"
+            Collection.objects.get(id=self.collection.id).name, "Skiing photos"
         )
 
 
@@ -336,95 +368,139 @@ class TestEditCollection(CollectionInstanceTestUtils, TestCase, WagtailTestUtils
         self.users_change_permission = GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.change_permission
+            permission=self.change_permission,
         )
         # Grant the marketing group permission to add collections under this collection
         self.users_add_permission = GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.add_permission
+            permission=self.add_permission,
         )
-        self.login(self.marketing_user, password='password')
+        self.login(self.marketing_user, password="password")
 
     def get(self, collection_id, params={}):
         return self.client.get(
-            reverse('wagtailadmin_collections:edit', args=(collection_id,)),
-            params
+            reverse("wagtailadmin_collections:edit", args=(collection_id,)), params
         )
 
     def post(self, collection_id, post_data={}):
         return self.client.post(
-            reverse('wagtailadmin_collections:edit', args=(collection_id,)),
-            post_data
+            reverse("wagtailadmin_collections:edit", args=(collection_id,)), post_data
         )
 
     def test_marketing_user_no_change_permission(self):
         self.users_change_permission.delete()
         response = self.get(collection_id=self.marketing_collection.id)
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response.context['message'], 'Sorry, you do not have permission to access this area.')
+        self.assertEqual(
+            response.context["message"],
+            "Sorry, you do not have permission to access this area.",
+        )
 
     def test_marketing_user_no_change_permission_post(self):
         self.users_change_permission.delete()
         response = self.post(self.marketing_collection.id, {})
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response.context['message'], 'Sorry, you do not have permission to access this area.')
+        self.assertEqual(
+            response.context["message"],
+            "Sorry, you do not have permission to access this area.",
+        )
 
     def test_marketing_user_can_move_collection(self):
         # Retrieve edit form and check fields
         response = self.get(collection_id=self.marketing_sub_collection.id)
         self.assertEqual(response.status_code, 200)
-        form_fields = response.context['form'].fields
-        self.assertEqual(type(form_fields['name'].widget).__name__, 'TextInput')
-        self.assertEqual(type(form_fields['parent'].widget).__name__, 'SelectWithDisabledOptions')
+        form_fields = response.context["form"].fields
+        self.assertEqual(type(form_fields["name"].widget).__name__, "TextInput")
+        self.assertEqual(
+            type(form_fields["parent"].widget).__name__, "SelectWithDisabledOptions"
+        )
         # Now move the collection and check it did get moved and renamed
-        self.post(self.marketing_sub_collection.pk, {'name': "New Collection Name", 'parent': self.marketing_sub_collection_2.pk})
-        self.assertEqual(Collection.objects.get(pk=self.marketing_sub_collection.pk).name, "New Collection Name")
-        self.assertEqual(Collection.objects.get(pk=self.marketing_sub_collection.pk).get_parent(), self.marketing_sub_collection_2)
+        self.post(
+            self.marketing_sub_collection.pk,
+            {
+                "name": "New Collection Name",
+                "parent": self.marketing_sub_collection_2.pk,
+            },
+        )
+        self.assertEqual(
+            Collection.objects.get(pk=self.marketing_sub_collection.pk).name,
+            "New Collection Name",
+        )
+        self.assertEqual(
+            Collection.objects.get(pk=self.marketing_sub_collection.pk).get_parent(),
+            self.marketing_sub_collection_2,
+        )
 
     def test_marketing_user_cannot_move_collection_if_no_add_permission(self):
         self.users_add_permission.delete()
         response = self.get(collection_id=self.marketing_sub_collection.id)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(list(response.context['form'].fields.keys()), ['name'])
+        self.assertEqual(list(response.context["form"].fields.keys()), ["name"])
         # Now try to move the collection and check it did not get moved
 
     def test_marketing_user_cannot_move_collection_if_no_add_permission_post(self):
         self.users_add_permission.delete()
-        self.post(self.marketing_sub_collection.pk, {'name': "New Collection Name", 'parent': self.marketing_sub_collection_2.pk})
+        self.post(
+            self.marketing_sub_collection.pk,
+            {
+                "name": "New Collection Name",
+                "parent": self.marketing_sub_collection_2.pk,
+            },
+        )
         edited_collection = Collection.objects.get(pk=self.marketing_sub_collection.id)
         self.assertEqual(edited_collection.name, "New Collection Name")
         self.assertEqual(edited_collection.get_parent(), self.marketing_collection)
 
     def test_cannot_move_parent_collection_to_descendant(self):
-        self.post(self.marketing_collection.pk, {'name': "New Collection Name", 'parent': self.marketing_sub_collection_2.pk})
+        self.post(
+            self.marketing_collection.pk,
+            {
+                "name": "New Collection Name",
+                "parent": self.marketing_sub_collection_2.pk,
+            },
+        )
         self.assertEqual(
             Collection.objects.get(pk=self.marketing_collection.pk).get_parent(),
-            self.root_collection
+            self.root_collection,
         )
 
     def test_marketing_user_cannot_move_collection_permissions_are_assigned_to(self):
         response = self.get(collection_id=self.marketing_collection.id)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(list(response.context['form'].fields.keys()), ['name'])
+        self.assertEqual(list(response.context["form"].fields.keys()), ["name"])
         self.assertNotContains(response, "Delete collection")
 
-    def test_marketing_user_cannot_move_collection_permissions_are_assigned_to_post(self):
+    def test_marketing_user_cannot_move_collection_permissions_are_assigned_to_post(
+        self,
+    ):
         # Grant the marketing group permission to another collection so there is a valid destination
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.finance_collection,
-            permission=self.add_permission
+            permission=self.add_permission,
         )
         # We can move nodes lower on the tree
-        self.post(self.marketing_sub_collection.id, {'name': "Moved Sub", 'parent': self.finance_collection.id})
-        self.assertEqual(Collection.objects.get(pk=self.marketing_sub_collection.pk).get_parent(), self.finance_collection)
+        self.post(
+            self.marketing_sub_collection.id,
+            {"name": "Moved Sub", "parent": self.finance_collection.id},
+        )
+        self.assertEqual(
+            Collection.objects.get(pk=self.marketing_sub_collection.pk).get_parent(),
+            self.finance_collection,
+        )
 
         # But we can't move the node to which our edit permission was assigned; update is ignored
-        self.post(self.marketing_collection.id, {'name': self.marketing_collection.name, 'parent': self.finance_collection.id})
+        self.post(
+            self.marketing_collection.id,
+            {
+                "name": self.marketing_collection.name,
+                "parent": self.finance_collection.id,
+            },
+        )
         self.assertEqual(
             Collection.objects.get(pk=self.marketing_collection.pk).get_parent(),
-            self.root_collection
+            self.root_collection,
         )
 
     def test_page_shows_delete_link_only_if_delete_permitted(self):
@@ -435,7 +511,7 @@ class TestEditCollection(CollectionInstanceTestUtils, TestCase, WagtailTestUtils
         GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.delete_permission
+            permission=self.delete_permission,
         )
         response = self.get(collection_id=self.marketing_sub_collection.id)
         self.assertContains(response, "Delete collection")
@@ -449,20 +525,26 @@ class TestDeleteCollectionAsSuperuser(TestCase, WagtailTestUtils):
 
     def get(self, params={}, collection_id=None):
         return self.client.get(
-            reverse('wagtailadmin_collections:delete', args=(collection_id or self.collection.id,)),
-            params
+            reverse(
+                "wagtailadmin_collections:delete",
+                args=(collection_id or self.collection.id,),
+            ),
+            params,
         )
 
     def post(self, post_data={}, collection_id=None):
         return self.client.post(
-            reverse('wagtailadmin_collections:delete', args=(collection_id or self.collection.id,)),
-            post_data
+            reverse(
+                "wagtailadmin_collections:delete",
+                args=(collection_id or self.collection.id,),
+            ),
+            post_data,
         )
 
     def test_get(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/generic/confirm_delete.html')
+        self.assertTemplateUsed(response, "wagtailadmin/generic/confirm_delete.html")
 
     def test_cannot_delete_root_collection(self):
         response = self.get(collection_id=self.root_collection.id)
@@ -473,35 +555,35 @@ class TestDeleteCollectionAsSuperuser(TestCase, WagtailTestUtils):
         self.assertEqual(response.status_code, 404)
 
     def test_get_nonempty_collection(self):
-        Document.objects.create(
-            title="Test document", collection=self.collection
-        )
+        Document.objects.create(title="Test document", collection=self.collection)
 
         response = self.get()
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/collections/delete_not_empty.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/collections/delete_not_empty.html"
+        )
 
     def test_get_collection_with_descendent(self):
-        self.collection.add_child(instance=Collection(name='Test collection'))
+        self.collection.add_child(instance=Collection(name="Test collection"))
 
         response = self.get()
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/collections/delete_not_empty.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/collections/delete_not_empty.html"
+        )
 
     def test_post(self):
         response = self.post()
 
         # Should redirect back to index
-        self.assertRedirects(response, reverse('wagtailadmin_collections:index'))
+        self.assertRedirects(response, reverse("wagtailadmin_collections:index"))
 
         # Check that the collection was deleted
         with self.assertRaises(Collection.DoesNotExist):
             Collection.objects.get(id=self.collection.id)
 
     def test_post_nonempty_collection(self):
-        Document.objects.create(
-            title="Test document", collection=self.collection
-        )
+        Document.objects.create(title="Test document", collection=self.collection)
 
         response = self.post()
         self.assertEqual(response.status_code, 403)
@@ -510,7 +592,7 @@ class TestDeleteCollectionAsSuperuser(TestCase, WagtailTestUtils):
         self.assertTrue(Collection.objects.get(id=self.collection.id))
 
     def test_post_collection_with_descendant(self):
-        self.collection.add_child(instance=Collection(name='Test collection'))
+        self.collection.add_child(instance=Collection(name="Test collection"))
 
         response = self.post()
         self.assertEqual(response.status_code, 403)
@@ -535,31 +617,29 @@ class TestDeleteCollection(CollectionInstanceTestUtils, TestCase, WagtailTestUti
         self.users_delete_permission = GroupCollectionPermission.objects.create(
             group=self.marketing_group,
             collection=self.marketing_collection,
-            permission=self.delete_permission
+            permission=self.delete_permission,
         )
-        self.login(self.marketing_user, password='password')
+        self.login(self.marketing_user, password="password")
 
     def get(self, collection_id, params={}):
         return self.client.get(
-            reverse('wagtailadmin_collections:delete', args=(collection_id,)),
-            params
+            reverse("wagtailadmin_collections:delete", args=(collection_id,)), params
         )
 
     def post(self, collection_id, post_data={}):
         return self.client.post(
-            reverse('wagtailadmin_collections:delete', args=(collection_id,)),
-            post_data
+            reverse("wagtailadmin_collections:delete", args=(collection_id,)), post_data
         )
 
     def test_get(self):
         response = self.get(collection_id=self.marketing_sub_collection.id)
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/generic/confirm_delete.html')
+        self.assertTemplateUsed(response, "wagtailadmin/generic/confirm_delete.html")
 
     def test_post(self):
         response = self.post(collection_id=self.marketing_sub_collection.id)
         # Should redirect back to index
-        self.assertRedirects(response, reverse('wagtailadmin_collections:index'))
+        self.assertRedirects(response, reverse("wagtailadmin_collections:index"))
 
         # Check that the collection was deleted
         with self.assertRaises(Collection.DoesNotExist):
@@ -586,14 +666,20 @@ class TestDeleteCollection(CollectionInstanceTestUtils, TestCase, WagtailTestUti
         self.assertTrue(Collection.objects.get(id=self.marketing_collection.id))
 
     def test_cannot_delete_collection_with_descendants(self):
-        self.marketing_sub_collection.add_child(instance=Collection(name='Another collection'))
+        self.marketing_sub_collection.add_child(
+            instance=Collection(name="Another collection")
+        )
 
         response = self.get(self.marketing_sub_collection.id)
         self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'wagtailadmin/collections/delete_not_empty.html')
+        self.assertTemplateUsed(
+            response, "wagtailadmin/collections/delete_not_empty.html"
+        )
 
     def test_cannot_delete_collection_with_descendants_post(self):
-        self.marketing_sub_collection.add_child(instance=Collection(name='Another collection'))
+        self.marketing_sub_collection.add_child(
+            instance=Collection(name="Another collection")
+        )
 
         response = self.post(self.marketing_sub_collection.id)
         self.assertEqual(response.status_code, 403)

Plik diff jest za duży
+ 560 - 230
wagtail/admin/tests/test_compare.py


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików