~netlandish/django-wiki

bdea598d400ade44db2a0ab2567f8c475a570748 — Benjamin Balder Bach 4 years ago f940b2d + 254c4a2
Merge pull request #1011 from benjaoming/add-black

Add black and lint full repo (reproducible!)
148 files changed, 4612 insertions(+), 3859 deletions(-)

M .pre-commit-config.yaml
M docs/conf.py
M setup.py
M src/wiki/__init__.py
M src/wiki/admin.py
M src/wiki/apps.py
M src/wiki/checks.py
M src/wiki/conf/settings.py
M src/wiki/core/exceptions.py
M src/wiki/core/http.py
M src/wiki/core/markdown/__init__.py
M src/wiki/core/markdown/mdx/codehilite.py
M src/wiki/core/markdown/mdx/previewlinks.py
M src/wiki/core/markdown/mdx/responsivetable.py
M src/wiki/core/paginator.py
M src/wiki/core/permissions.py
M src/wiki/core/plugins/base.py
M src/wiki/core/plugins/loader.py
M src/wiki/core/plugins/registry.py
M src/wiki/core/utils.py
M src/wiki/core/version.py
M src/wiki/decorators.py
M src/wiki/editors/base.py
M src/wiki/editors/markitup.py
M src/wiki/forms.py
M src/wiki/forms_account_handling.py
M src/wiki/managers.py
M src/wiki/migrations/0001_initial.py
M src/wiki/migrations/0002_urlpath_moved_to.py
M src/wiki/models/__init__.py
M src/wiki/models/article.py
M src/wiki/models/pluginbase.py
M src/wiki/models/urlpath.py
M src/wiki/plugins/attachments/__init__.py
M src/wiki/plugins/attachments/admin.py
M src/wiki/plugins/attachments/apps.py
M src/wiki/plugins/attachments/forms.py
M src/wiki/plugins/attachments/markdown_extensions.py
M src/wiki/plugins/attachments/migrations/0001_initial.py
M src/wiki/plugins/attachments/migrations/0002_auto_20151118_1816.py
M src/wiki/plugins/attachments/models.py
M src/wiki/plugins/attachments/settings.py
M src/wiki/plugins/attachments/urls.py
M src/wiki/plugins/attachments/views.py
M src/wiki/plugins/attachments/wiki_plugin.py
M src/wiki/plugins/editsection/__init__.py
M src/wiki/plugins/editsection/apps.py
M src/wiki/plugins/editsection/markdown_extensions.py
M src/wiki/plugins/editsection/settings.py
M src/wiki/plugins/editsection/views.py
M src/wiki/plugins/editsection/wiki_plugin.py
M src/wiki/plugins/globalhistory/__init__.py
M src/wiki/plugins/globalhistory/apps.py
M src/wiki/plugins/globalhistory/settings.py
M src/wiki/plugins/globalhistory/views.py
M src/wiki/plugins/globalhistory/wiki_plugin.py
M src/wiki/plugins/help/__init__.py
M src/wiki/plugins/help/apps.py
M src/wiki/plugins/help/wiki_plugin.py
M src/wiki/plugins/images/__init__.py
M src/wiki/plugins/images/admin.py
M src/wiki/plugins/images/apps.py
M src/wiki/plugins/images/checks.py
M src/wiki/plugins/images/forms.py
M src/wiki/plugins/images/markdown_extensions.py
M src/wiki/plugins/images/migrations/0001_initial.py
M src/wiki/plugins/images/migrations/0002_auto_20151118_1811.py
M src/wiki/plugins/images/models.py
M src/wiki/plugins/images/settings.py
M src/wiki/plugins/images/templatetags/wiki_images_tags.py
M src/wiki/plugins/images/views.py
M src/wiki/plugins/images/wiki_plugin.py
M src/wiki/plugins/links/__init__.py
M src/wiki/plugins/links/apps.py
M src/wiki/plugins/links/mdx/djangowikilinks.py
M src/wiki/plugins/links/mdx/urlize.py
M src/wiki/plugins/links/settings.py
M src/wiki/plugins/links/views.py
M src/wiki/plugins/links/wiki_plugin.py
M src/wiki/plugins/macros/__init__.py
M src/wiki/plugins/macros/apps.py
M src/wiki/plugins/macros/mdx/macro.py
M src/wiki/plugins/macros/mdx/toc.py
M src/wiki/plugins/macros/mdx/wikilinks.py
M src/wiki/plugins/macros/settings.py
M src/wiki/plugins/macros/templatetags/wiki_macro_tags.py
M src/wiki/plugins/macros/wiki_plugin.py
M src/wiki/plugins/notifications/__init__.py
M src/wiki/plugins/notifications/apps.py
M src/wiki/plugins/notifications/forms.py
M src/wiki/plugins/notifications/management/commands/wiki_notifications_create_defaults.py
M src/wiki/plugins/notifications/migrations/0001_initial.py
M src/wiki/plugins/notifications/migrations/0002_auto_20151118_1811.py
M src/wiki/plugins/notifications/models.py
M src/wiki/plugins/notifications/settings.py
M src/wiki/plugins/notifications/views.py
M src/wiki/plugins/notifications/wiki_plugin.py
M src/wiki/plugins/redlinks/__init__.py
M src/wiki/plugins/redlinks/apps.py
M src/wiki/plugins/redlinks/mdx/redlinks.py
M src/wiki/plugins/redlinks/wiki_plugin.py
M src/wiki/sites.py
M src/wiki/static/wiki/bootstrap/css/wiki-bootstrap.min.css
M src/wiki/templatetags/wiki_tags.py
M src/wiki/urls.py
M src/wiki/views/accounts.py
M src/wiki/views/article.py
M src/wiki/views/deleted_list.py
M src/wiki/views/mixins.py
M testproject/testproject/settings/base.py
M testproject/testproject/settings/codehilite.py
M testproject/testproject/settings/customauthuser.py
M testproject/testproject/settings/dev.py
M testproject/testproject/settings/sendfile.py
M testproject/testproject/urls.py
M testproject/testproject/views.py
M tests/base.py
M tests/core/test_accounts.py
M tests/core/test_basic.py
M tests/core/test_checks.py
M tests/core/test_commands.py
M tests/core/test_forms.py
M tests/core/test_managers.py
M tests/core/test_markdown.py
M tests/core/test_models.py
M tests/core/test_sites.py
M tests/core/test_template_filters.py
M tests/core/test_template_tags.py
M tests/core/test_urls.py
M tests/core/test_utils.py
M tests/core/test_views.py
M tests/plugins/attachments/test_commands.py
M tests/plugins/attachments/test_models.py
M tests/plugins/attachments/test_views.py
M tests/plugins/editsection/test_editsection.py
M tests/plugins/globalhistory/test_globalhistory.py
M tests/plugins/images/test_forms.py
M tests/plugins/images/test_views.py
M tests/plugins/links/test_links.py
M tests/plugins/links/test_urlize.py
M tests/plugins/macros/test_links.py
M tests/plugins/macros/test_toc.py
M tests/plugins/notifications/test_forms.py
M tests/plugins/notifications/test_views.py
M tests/settings.py
M tests/testdata/migrations/0001_initial.py
M tests/testdata/models.py
M tests/testdata/urls.py
M .pre-commit-config.yaml => .pre-commit-config.yaml +5 -6
@@ 1,5 1,5 @@
repos:
-   repo: git://github.com/pre-commit/pre-commit-hooks
  - repo: git://github.com/pre-commit/pre-commit-hooks
    sha: v1.2.0
    hooks:
    -   id: trailing-whitespace


@@ 9,9 9,8 @@ repos:
    -   id: check-added-large-files
    -   id: debug-statements
    -   id: end-of-file-fixer
-   repo: git://github.com/FalconSocial/pre-commit-python-sorter
    sha: b57843b0b874df1d16eb0bef00b868792cb245c2
  - repo: https://github.com/psf/black
    rev: stable
    hooks:
    -   id: python-import-sorter
        args:
        - --silent-overwrite
    - id: black
      language_version: python3.6

M docs/conf.py => docs/conf.py +42 -35
@@ 37,8 37,8 @@ import django
# 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('../src'))
sys.path.insert(0, os.path.abspath('../testproject'))
sys.path.insert(0, os.path.abspath("../src"))
sys.path.insert(0, os.path.abspath("../testproject"))

# -- General configuration ------------------------------------------------



@@ 62,7 62,7 @@ def process_docstring(app, what, name, obj, options, lines):

        for field in fields:
            # Skip ManyToOneRel and ManyToManyRel fields which have no 'verbose_name' or 'help_text'
            if not hasattr(field, 'verbose_name'):
            if not hasattr(field, "verbose_name"):
                continue

            # Decode and strip any html out of the field's help text


@@ 75,62 75,65 @@ def process_docstring(app, what, name, obj, options, lines):
            if help_text:
                # Add the model field to the end of the docstring as a param
                # using the help text as the description
                lines.append(u':param %s: %s' % (field.attname, help_text))
                lines.append(u":param %s: %s" % (field.attname, help_text))
            else:
                # Add the model field to the end of the docstring as a param
                # using the verbose name as the description
                lines.append(u':param %s: %s' % (field.attname, verbose_name))
                lines.append(u":param %s: %s" % (field.attname, verbose_name))

            # Add the field's type to the docstring
            if isinstance(field, models.ForeignKey):
                for to in field.to_fields:
                    lines.append(u':type %s: %s to :class:`~%s`' % (field.attname, type(field).__name__, to))
                    lines.append(
                        u":type %s: %s to :class:`~%s`"
                        % (field.attname, type(field).__name__, to)
                    )
            else:
                lines.append(u':type %s: %s' % (field.attname, type(field).__name__))
                lines.append(u":type %s: %s" % (field.attname, type(field).__name__))

    return lines


extlinks = {
    'url-issue': ('https://github.com/django-wiki/django-wiki/issues/%s', '#'),
    "url-issue": ("https://github.com/django-wiki/django-wiki/issues/%s", "#"),
}


def setup(app):
    # Register the docstring processor with sphinx
    app.connect('autodoc-process-docstring', process_docstring)
    app.connect("autodoc-process-docstring", process_docstring)


# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
    'sphinx.ext.autodoc',
    'sphinx.ext.extlinks',
    'sphinx.ext.todo',
    'sphinx.ext.viewcode',
    "sphinx.ext.autodoc",
    "sphinx.ext.extlinks",
    "sphinx.ext.todo",
    "sphinx.ext.viewcode",
]

# 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 = 'django-wiki'
copyright = '{}, Benjamin Bach'.format(datetime.now().year)  # noqa
project = "django-wiki"
copyright = "{}, Benjamin Bach".format(datetime.now().year)  # noqa


path = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))

sys.path = [path] + sys.path
sys.path = [os.path.join(path, 'wiki')] + sys.path
sys.path = [os.path.join(path, "wiki")] + sys.path


import wiki  # noqa


@@ 157,7 160,7 @@ release = wiki.__version__

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
exclude_patterns = ["_build"]

# The reST default role (used for this markup: `text`) to use for all documents.
# default_role = None


@@ 174,25 177,25 @@ exclude_patterns = ['_build']
# show_authors = False

# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
pygments_style = "sphinx"

# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []

linkcheck_ignore = [
    r'wiki.+',
    r"wiki.+",
]


# -- Options for HTML output ---------------------------------------------------

on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
on_rtd = os.environ.get("READTHEDOCS", None) == "True"
if on_rtd:
    os.system("sphinx-apidoc --doc-project='Python Reference' -f -o . ../wiki")
if on_rtd:
    html_theme = 'default'
    html_theme = "default"
else:
    html_theme = 'sphinx_rtd_theme'
    html_theme = "sphinx_rtd_theme"

# Theme options are theme-specific and customize the look and feel of a theme
# further.  For a list of options available for each theme, see the


@@ 265,7 268,7 @@ html_static_path = []
# html_file_suffix = None

# Output file base name for HTML help builder.
htmlhelp_basename = 'django-wikidoc'
htmlhelp_basename = "django-wikidoc"


# -- Options for LaTeX output --------------------------------------------------


@@ 273,10 276,8 @@ htmlhelp_basename = 'django-wikidoc'
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': '',
}


@@ 285,8 286,11 @@ latex_elements = {
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
    (
        'index', 'django-wiki.tex', 'django-wiki Documentation',
        'Benjamin Bach', 'manual'
        "index",
        "django-wiki.tex",
        "django-wiki Documentation",
        "Benjamin Bach",
        "manual",
    ),
]



@@ 316,8 320,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
    ('index', 'django-wiki', 'django-wiki Documentation',
     ['Benjamin Bach'], 1)
    ("index", "django-wiki", "django-wiki Documentation", ["Benjamin Bach"], 1)
]

# If true, show URL addresses after external links.


@@ 331,9 334,13 @@ man_pages = [
#  dir menu entry, description, category)
texinfo_documents = [
    (
        'index', 'django-wiki', 'django-wiki Documentation',
        'Benjamin Bach', 'django-wiki', 'Wiki engine for Django - with real data models!',
        'Miscellaneous'
        "index",
        "django-wiki",
        "django-wiki Documentation",
        "Benjamin Bach",
        "django-wiki",
        "Wiki engine for Django - with real data models!",
        "Miscellaneous",
    ),
]


M setup.py => setup.py +39 -39
@@ 7,9 7,7 @@ from glob import glob

from setuptools import find_packages, setup

sys.path.append(
    os.path.join(os.path.dirname(__file__), 'src')
)
sys.path.append(os.path.join(os.path.dirname(__file__), "src"))

# noqa
from wiki import __version__  # isort:skip  # noqa


@@ 31,34 29,34 @@ install_requirements = [
    "django-mptt>=0.9,<0.10",
    "django-sekizai>=0.10",
    "sorl-thumbnail>=12,<13",
    "Markdown>=3.1,<3.2.0"
    "Markdown>=3.1,<3.2.0",
]

test_requirements = [
    'django-functest>=1.0.3,<1.1',
    'pytest>=5.3,<5.4',
    'pytest-django',
    'pytest-cov',
    'pytest-pythonpath',
    "django-functest>=1.0.3,<1.1",
    "pytest>=5.3,<5.4",
    "pytest-django",
    "pytest-cov",
    "pytest-pythonpath",
]

test_lint_requirements = [
    'flake8>=3.7,<3.8',
    'flake8-isort',
    "flake8>=3.7,<3.8",
    "flake8-isort",
]

setup_requirements = [
    'pytest-runner',
    "pytest-runner",
]

development_requirements = test_requirements + test_lint_requirements + [
    'pre-commit',
]
development_requirements = (
    test_requirements + test_lint_requirements + ["pre-commit",]
)

extras_requirements = {
    'devel': development_requirements,
    'test': test_requirements,
    'testlint': test_lint_requirements,
    "devel": development_requirements,
    "test": test_requirements,
    "testlint": test_lint_requirements,
}

setup(


@@ 70,30 68,32 @@ setup(
    description="A wiki system written for the Django framework.",
    license="GPLv3",
    keywords=["django", "wiki", "markdown"],
    packages=find_packages('src'),
    package_dir={'': 'src'},
    py_modules=[os.path.splitext(os.path.basename(path))[0] for path in glob('src/*.py')],
    long_description=open('README.rst').read(),
    packages=find_packages("src"),
    package_dir={"": "src"},
    py_modules=[
        os.path.splitext(os.path.basename(path))[0] for path in glob("src/*.py")
    ],
    long_description=open("README.rst").read(),
    zip_safe=False,
    install_requires=install_requirements,
    classifiers=[
        'Development Status :: 5 - Production/Stable',
        'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
        'Environment :: Web Environment',
        'Framework :: Django',
        'Intended Audience :: Developers',
        'Operating System :: OS Independent',
        'Programming Language :: Python',
        'Programming Language :: Python :: 3 :: Only',
        'Programming Language :: Python :: 3.5',
        'Programming Language :: Python :: 3.6',
        'Programming Language :: Python :: 3.7',
        'Programming Language :: Python :: 3.8',
        'Programming Language :: Python :: Implementation :: CPython',
        'Programming Language :: Python :: Implementation :: PyPy',
        'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
        'Topic :: Software Development',
        'Topic :: Software Development :: Libraries :: Application Frameworks',
        "Development Status :: 5 - Production/Stable",
        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
        "Environment :: Web Environment",
        "Framework :: Django",
        "Intended Audience :: Developers",
        "Operating System :: OS Independent",
        "Programming Language :: Python",
        "Programming Language :: Python :: 3 :: Only",
        "Programming Language :: Python :: 3.5",
        "Programming Language :: Python :: 3.6",
        "Programming Language :: Python :: 3.7",
        "Programming Language :: Python :: 3.8",
        "Programming Language :: Python :: Implementation :: CPython",
        "Programming Language :: Python :: Implementation :: PyPy",
        "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
        "Topic :: Software Development",
        "Topic :: Software Development :: Libraries :: Application Frameworks",
    ],
    include_package_data=True,
    setup_requires=setup_requirements,

M src/wiki/__init__.py => src/wiki/__init__.py +2 -2
@@ 17,7 17,7 @@

from wiki.core.version import get_version

default_app_config = 'wiki.apps.WikiConfig'
default_app_config = "wiki.apps.WikiConfig"

VERSION = (0, 6, 0, 'alpha', 0)
VERSION = (0, 6, 0, "alpha", 0)
__version__ = get_version(VERSION)

M src/wiki/admin.py => src/wiki/admin.py +26 -19
@@ 11,11 11,10 @@ class ArticleObjectAdmin(GenericTabularInline):
    model = models.ArticleForObject
    extra = 1
    max_num = 1
    raw_id_fields = ('article',)
    raw_id_fields = ("article",)


class ArticleRevisionForm(forms.ModelForm):

    class Meta:
        model = models.ArticleRevision
        exclude = ()


@@ 24,12 23,12 @@ class ArticleRevisionForm(forms.ModelForm):
        super().__init__(*args, **kwargs)
        # TODO: This pattern is too weird
        editor = editors.getEditor()
        self.fields['content'].widget = editor.get_admin_widget()
        self.fields["content"].widget = editor.get_admin_widget()


class ArticleRevisionAdmin(admin.ModelAdmin):
    form = ArticleRevisionForm
    list_display = ('title', 'created', 'modified', 'user', 'ip_address')
    list_display = ("title", "created", "modified", "user", "ip_address")

    class Media:
        js = editors.getEditorClass().AdminMedia.js


@@ 39,9 38,14 @@ class ArticleRevisionAdmin(admin.ModelAdmin):
class ArticleRevisionInline(admin.TabularInline):
    model = models.ArticleRevision
    form = ArticleRevisionForm
    fk_name = 'article'
    fk_name = "article"
    extra = 1
    fields = ('content', 'title', 'deleted', 'locked',)
    fields = (
        "content",
        "title",
        "deleted",
        "locked",
    )

    class Media:
        js = editors.getEditorClass().AdminMedia.js


@@ 49,7 53,6 @@ class ArticleRevisionInline(admin.TabularInline):


class ArticleForm(forms.ModelForm):

    class Meta:
        model = models.Article
        exclude = ()


@@ 57,32 60,36 @@ class ArticleForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance.pk:
            revisions = models.ArticleRevision.objects.filter(
                article=self.instance)
            self.fields['current_revision'].queryset = revisions
            revisions = models.ArticleRevision.objects.filter(article=self.instance)
            self.fields["current_revision"].queryset = revisions
        else:
            self.fields[
                'current_revision'].queryset = models.ArticleRevision.objects.none()
            self.fields['current_revision'].widget = forms.HiddenInput()
                "current_revision"
            ].queryset = models.ArticleRevision.objects.none()
            self.fields["current_revision"].widget = forms.HiddenInput()


class ArticleAdmin(admin.ModelAdmin):
    inlines = [ArticleRevisionInline]
    form = ArticleForm
    search_fields = ('current_revision__title', 'current_revision__content')
    search_fields = ("current_revision__title", "current_revision__content")


class URLPathAdmin(MPTTModelAdmin):
    inlines = [ArticleObjectAdmin]
    list_filter = ('site', 'articles__article__current_revision__deleted',
                   'articles__article__created',
                   'articles__article__modified')
    list_display = ('__str__', 'article', 'get_created')
    raw_id_fields = ('article',)
    list_filter = (
        "site",
        "articles__article__current_revision__deleted",
        "articles__article__created",
        "articles__article__modified",
    )
    list_display = ("__str__", "article", "get_created")
    raw_id_fields = ("article",)

    def get_created(self, instance):
        return instance.article.created
    get_created.short_description = _('created')

    get_created.short_description = _("created")

    def save_model(self, request, obj, form, change):
        """

M src/wiki/apps.py => src/wiki/apps.py +13 -4
@@ 7,13 7,22 @@ from . import checks


class WikiConfig(AppConfig):
    default_site = 'wiki.sites.WikiSite'
    default_site = "wiki.sites.WikiSite"
    name = "wiki"
    verbose_name = _("Wiki")

    def ready(self):
        register(checks.check_for_required_installed_apps, checks.Tags.required_installed_apps)
        register(checks.check_for_obsolete_installed_apps, checks.Tags.obsolete_installed_apps)
        register(
            checks.check_for_required_installed_apps,
            checks.Tags.required_installed_apps,
        )
        register(
            checks.check_for_obsolete_installed_apps,
            checks.Tags.obsolete_installed_apps,
        )
        register(checks.check_for_context_processors, checks.Tags.context_processors)
        register(checks.check_for_fields_in_custom_user_model, checks.Tags.fields_in_custom_user_model)
        register(
            checks.check_for_fields_in_custom_user_model,
            checks.Tags.fields_in_custom_user_model,
        )
        load_wiki_plugins()

M src/wiki/checks.py => src/wiki/checks.py +35 -23
@@ 12,29 12,29 @@ class Tags:

REQUIRED_INSTALLED_APPS = (
    # module name, package name, error code
    ('mptt', 'django-mptt', 'E001'),
    ('sekizai', 'django-sekizai', 'E002'),
    ('django.contrib.humanize', 'django.contrib.humanize', 'E003'),
    ('django.contrib.contenttypes', 'django.contrib.contenttypes', 'E004'),
    ('django.contrib.sites', 'django.contrib.sites', 'E005'),
    ("mptt", "django-mptt", "E001"),
    ("sekizai", "django-sekizai", "E002"),
    ("django.contrib.humanize", "django.contrib.humanize", "E003"),
    ("django.contrib.contenttypes", "django.contrib.contenttypes", "E004"),
    ("django.contrib.sites", "django.contrib.sites", "E005"),
)

OBSOLETE_INSTALLED_APPS = (
    # obsolete module name, new module name, error code
    ('django_notify', 'django_nyt', 'E006'),
    ("django_notify", "django_nyt", "E006"),
)

REQUIRED_CONTEXT_PROCESSORS = (
    # context processor name, error code
    ('django.contrib.auth.context_processors.auth', 'E007'),
    ('django.template.context_processors.request', 'E008'),
    ('sekizai.context_processors.sekizai', 'E009'),
    ("django.contrib.auth.context_processors.auth", "E007"),
    ("django.template.context_processors.request", "E008"),
    ("sekizai.context_processors.sekizai", "E009"),
)

FIELDS_IN_CUSTOM_USER_MODEL = (
    # check function, field fetcher, required field type, error code
    ('check_user_field', 'USERNAME_FIELD', 'CharField', 'E010'),
    ('check_email_field', 'get_email_field_name()', 'EmailField', 'E011'),
    ("check_user_field", "USERNAME_FIELD", "CharField", "E010"),
    ("check_email_field", "get_email_field_name()", "EmailField", "E011"),
)




@@ 43,10 43,7 @@ def check_for_required_installed_apps(app_configs, **kwargs):
    for app in REQUIRED_INSTALLED_APPS:
        if not apps.is_installed(app[0]):
            errors.append(
                Error(
                    'needs %s in INSTALLED_APPS' % app[1],
                    id='wiki.%s' % app[2],
                )
                Error("needs %s in INSTALLED_APPS" % app[1], id="wiki.%s" % app[2],)
            )
    return errors



@@ 57,8 54,9 @@ def check_for_obsolete_installed_apps(app_configs, **kwargs):
        if apps.is_installed(app[0]):
            errors.append(
                Error(
                    'You need to change from %s to %s in INSTALLED_APPS and your urlconfig.' % (app[0], app[1]),
                    id='wiki.%s' % app[2],
                    "You need to change from %s to %s in INSTALLED_APPS and your urlconfig."
                    % (app[0], app[1]),
                    id="wiki.%s" % app[2],
                )
            )
    return errors


@@ 71,8 69,9 @@ def check_for_context_processors(app_configs, **kwargs):
        if context_processor[0] not in context_processors:
            errors.append(
                Error(
                    "needs %s in TEMPLATE['OPTIONS']['context_processors']" % context_processor[0],
                    id='wiki.%s' % context_processor[1],
                    "needs %s in TEMPLATE['OPTIONS']['context_processors']"
                    % context_processor[0],
                    id="wiki.%s" % context_processor[1],
                )
            )
    return errors


@@ 81,20 80,33 @@ def check_for_context_processors(app_configs, **kwargs):
def check_for_fields_in_custom_user_model(app_configs, **kwargs):
    errors = []
    from wiki.conf import settings

    if not settings.ACCOUNT_HANDLING:
        return errors
    import wiki.forms_account_handling
    from django.contrib.auth import get_user_model

    User = get_user_model()
    for check_function_name, field_fetcher, required_field_type, error_code in FIELDS_IN_CUSTOM_USER_MODEL:
    for (
        check_function_name,
        field_fetcher,
        required_field_type,
        error_code,
    ) in FIELDS_IN_CUSTOM_USER_MODEL:
        function = getattr(wiki.forms_account_handling, check_function_name)
        if not function(User):
            errors.append(
                Error(
                    '%s.%s.%s refers to a field that is not of type %s' % (User.__module__, User.__name__, field_fetcher, required_field_type),
                    hint='If you have your own login/logout views, turn off settings.WIKI_ACCOUNT_HANDLING',
                    "%s.%s.%s refers to a field that is not of type %s"
                    % (
                        User.__module__,
                        User.__name__,
                        field_fetcher,
                        required_field_type,
                    ),
                    hint="If you have your own login/logout views, turn off settings.WIKI_ACCOUNT_HANDLING",
                    obj=User,
                    id='wiki.%s' % error_code,
                    id="wiki.%s" % error_code,
                )
            )
    return errors

M src/wiki/conf/settings.py => src/wiki/conf/settings.py +91 -133
@@ 6,24 6,18 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _

#: Should urls be case sensitive?
URL_CASE_SENSITIVE = getattr(django_settings, 'WIKI_URL_CASE_SENSITIVE', False)
URL_CASE_SENSITIVE = getattr(django_settings, "WIKI_URL_CASE_SENSITIVE", False)

# Non-configurable (at the moment)
WIKI_LANGUAGE = 'markdown'
WIKI_LANGUAGE = "markdown"

#: The editor class to use -- maybe a 3rd party or your own...? You can always
#: extend the built-in editor and customize it!
EDITOR = getattr(
    django_settings,
    'WIKI_EDITOR',
    'wiki.editors.markitup.MarkItUp')
EDITOR = getattr(django_settings, "WIKI_EDITOR", "wiki.editors.markitup.MarkItUp")

#: Whether to use Bleach or not. It's not recommended to turn this off unless
#: you know what you're doing and you don't want to use the other options.
MARKDOWN_SANITIZE_HTML = getattr(
    django_settings,
    'WIKI_MARKDOWN_SANITIZE_HTML',
    True)
MARKDOWN_SANITIZE_HTML = getattr(django_settings, "WIKI_MARKDOWN_SANITIZE_HTML", True)

#: Arguments for the Markdown instance, as a dictionary. The "extensions" key
#: should be a list of extra extensions to use besides the built-in django-wiki


@@ 48,130 42,113 @@ MARKDOWN_SANITIZE_HTML = getattr(
#: "wiki.core.markdown.mdx.responsivetable", "wiki.plugins.macros.mdx.macro",
#: "wiki.plugins.macros.mdx.toc", "wiki.plugins.macros.mdx.wikilinks".
MARKDOWN_KWARGS = {
    'extensions': [
        'markdown.extensions.footnotes',
        'markdown.extensions.attr_list',
        'markdown.extensions.footnotes',
        'markdown.extensions.attr_list',
        'markdown.extensions.def_list',
        'markdown.extensions.tables',
        'markdown.extensions.abbr',
        'markdown.extensions.sane_lists',
    "extensions": [
        "markdown.extensions.footnotes",
        "markdown.extensions.attr_list",
        "markdown.extensions.footnotes",
        "markdown.extensions.attr_list",
        "markdown.extensions.def_list",
        "markdown.extensions.tables",
        "markdown.extensions.abbr",
        "markdown.extensions.sane_lists",
    ],
    'extension_configs': {
        'wiki.plugins.macros.mdx.toc': {'title': _('Contents')},
    },
    "extension_configs": {"wiki.plugins.macros.mdx.toc": {"title": _("Contents")},},
}
MARKDOWN_KWARGS.update(getattr(django_settings, 'WIKI_MARKDOWN_KWARGS', {}))

_default_tag_whitelists = bleach.ALLOWED_TAGS + [
    'figure',
    'figcaption',
    'br',
    'hr',
    'p',
    'div',
    'img',
    'pre',
    'span',
    'sup',
    'table',
    'thead',
    'tbody',
    'th',
    'tr',
    'td',
    'dl',
    'dt',
    'dd',
] + ['h{}'.format(n) for n in range(1, 7)]
MARKDOWN_KWARGS.update(getattr(django_settings, "WIKI_MARKDOWN_KWARGS", {}))

_default_tag_whitelists = (
    bleach.ALLOWED_TAGS
    + [
        "figure",
        "figcaption",
        "br",
        "hr",
        "p",
        "div",
        "img",
        "pre",
        "span",
        "sup",
        "table",
        "thead",
        "tbody",
        "th",
        "tr",
        "td",
        "dl",
        "dt",
        "dd",
    ]
    + ["h{}".format(n) for n in range(1, 7)]
)


#: List of allowed tags in Markdown article contents.
MARKDOWN_HTML_WHITELIST = _default_tag_whitelists
MARKDOWN_HTML_WHITELIST += (
    getattr(
        django_settings,
        'WIKI_MARKDOWN_HTML_WHITELIST',
        []
    )
)
MARKDOWN_HTML_WHITELIST += getattr(django_settings, "WIKI_MARKDOWN_HTML_WHITELIST", [])

_default_attribute_whitelist = bleach.ALLOWED_ATTRIBUTES
for tag in MARKDOWN_HTML_WHITELIST:
    if tag not in _default_attribute_whitelist:
        _default_attribute_whitelist[tag] = []
    _default_attribute_whitelist[tag].append('class')
    _default_attribute_whitelist[tag].append('id')
    _default_attribute_whitelist[tag].append('target')
    _default_attribute_whitelist[tag].append('rel')
    _default_attribute_whitelist[tag].append("class")
    _default_attribute_whitelist[tag].append("id")
    _default_attribute_whitelist[tag].append("target")
    _default_attribute_whitelist[tag].append("rel")

_default_attribute_whitelist['img'].append('src')
_default_attribute_whitelist['img'].append('alt')
_default_attribute_whitelist["img"].append("src")
_default_attribute_whitelist["img"].append("alt")

#: Dictionary of allowed attributes in Markdown article contents.
MARKDOWN_HTML_ATTRIBUTES = _default_attribute_whitelist
MARKDOWN_HTML_ATTRIBUTES.update(
    getattr(
        django_settings,
        'WIKI_MARKDOWN_HTML_ATTRIBUTES',
        {}
    )
    getattr(django_settings, "WIKI_MARKDOWN_HTML_ATTRIBUTES", {})
)

#: Allowed inline styles in Markdown article contents, default is no styles
#: (empty list).
MARKDOWN_HTML_STYLES = (
    getattr(
        django_settings,
        'WIKI_MARKDOWN_HTML_STYLES',
        []
    )
)
MARKDOWN_HTML_STYLES = getattr(django_settings, "WIKI_MARKDOWN_HTML_STYLES", [])

_project_defined_attrs = getattr(
    django_settings,
    'WIKI_MARKDOWN_HTML_ATTRIBUTE_WHITELIST',
    False)
    django_settings, "WIKI_MARKDOWN_HTML_ATTRIBUTE_WHITELIST", False
)

# If styles are allowed but no custom attributes are defined, we allow styles
# for all kinds of tags.
if MARKDOWN_HTML_STYLES and not _project_defined_attrs:
    MARKDOWN_HTML_ATTRIBUTES['*'] = 'style'
    MARKDOWN_HTML_ATTRIBUTES["*"] = "style"


#: This slug is used in URLPath if an article has been deleted. The children of the
#: URLPath of that article are moved to lost and found. They keep their permissions
#: and all their content.
LOST_AND_FOUND_SLUG = getattr(
    django_settings,
    'WIKI_LOST_AND_FOUND_SLUG',
    'lost-and-found')
    django_settings, "WIKI_LOST_AND_FOUND_SLUG", "lost-and-found"
)

#: When True, this blocks new slugs that resolve to non-wiki views, stopping
#: users creating articles that conflict with overlapping URLs from other apps.
CHECK_SLUG_URL_AVAILABLE = getattr(
    django_settings,
    'WIKI_CHECK_SLUG_URL_AVAILABLE',
    True)
    django_settings, "WIKI_CHECK_SLUG_URL_AVAILABLE", True
)

#: Do we want to log IPs of anonymous users?
LOG_IPS_ANONYMOUS = getattr(django_settings, 'WIKI_LOG_IPS_ANONYMOUS', True)
LOG_IPS_ANONYMOUS = getattr(django_settings, "WIKI_LOG_IPS_ANONYMOUS", True)

#: Do we want to log IPs of logged in users?
LOG_IPS_USERS = getattr(django_settings, 'WIKI_LOG_IPS_USERS', False)
LOG_IPS_USERS = getattr(django_settings, "WIKI_LOG_IPS_USERS", False)

#: Mapping from message.level to bootstrap class names.
MESSAGE_TAG_CSS_CLASS = getattr(
    django_settings,
    'WIKI_MESSAGE_TAG_CSS_CLASS',
    "WIKI_MESSAGE_TAG_CSS_CLASS",
    {
        messages.DEBUG: "alert alert-info",
        messages.ERROR: "alert alert-danger",
        messages.INFO: "alert alert-info",
        messages.SUCCESS: "alert alert-success",
        messages.WARNING: "alert alert-warning",
    }
    },
)

####################################


@@ 184,68 161,62 @@ MESSAGE_TAG_CSS_CLASS = getattr(
#: A function returning True/False if a user has permission to
#: read contents of an article and plugins.
#: Relevance: Viewing articles and plugins.
CAN_READ = getattr(django_settings, 'WIKI_CAN_READ', None)
CAN_READ = getattr(django_settings, "WIKI_CAN_READ", None)

#: A function returning True/False if a user has permission to
#: change contents, i.e. add new revisions to an article.
#: Often, plugins also use this.
#: Relevance: Editing articles, changing revisions, editing plugins.
CAN_WRITE = getattr(django_settings, 'WIKI_CAN_WRITE', None)
CAN_WRITE = getattr(django_settings, "WIKI_CAN_WRITE", None)

#: A function returning True/False if a user has permission to assign
#: permissions on an article.
#: Relevance: Changing owner and group membership.
CAN_ASSIGN = getattr(django_settings, 'WIKI_CAN_ASSIGN', None)
CAN_ASSIGN = getattr(django_settings, "WIKI_CAN_ASSIGN", None)

#: A function returning True/False if the owner of an article has permission
#: to change the group to a user's own groups.
#: Relevance: Changing group membership.
CAN_ASSIGN_OWNER = getattr(django_settings, 'WIKI_ASSIGN_OWNER', None)
CAN_ASSIGN_OWNER = getattr(django_settings, "WIKI_ASSIGN_OWNER", None)

#: A function returning True/False if a user has permission to change
#: read/write access for groups and others.
CAN_CHANGE_PERMISSIONS = getattr(
    django_settings,
    'WIKI_CAN_CHANGE_PERMISSIONS',
    None)
CAN_CHANGE_PERMISSIONS = getattr(django_settings, "WIKI_CAN_CHANGE_PERMISSIONS", None)

#: Specifies if a user has access to soft deletion of articles.
CAN_DELETE = getattr(django_settings, 'WIKI_CAN_DELETE', None)
CAN_DELETE = getattr(django_settings, "WIKI_CAN_DELETE", None)

#: A function returning True/False if a user has permission to change
#: moderate, ie. lock articles and permanently delete content.
CAN_MODERATE = getattr(django_settings, 'WIKI_CAN_MODERATE', None)
CAN_MODERATE = getattr(django_settings, "WIKI_CAN_MODERATE", None)

#: A function returning True/False if a user has permission to create
#: new groups and users for the wiki.
CAN_ADMIN = getattr(django_settings, 'WIKI_CAN_ADMIN', None)
CAN_ADMIN = getattr(django_settings, "WIKI_CAN_ADMIN", None)

#: Treat anonymous (i.e. non logged in) users as the "other" user group.
ANONYMOUS = getattr(django_settings, 'WIKI_ANONYMOUS', True)
ANONYMOUS = getattr(django_settings, "WIKI_ANONYMOUS", True)

#: Globally enable write access for anonymous users, if true anonymous users
#: will be treated as the others_write boolean field on models.Article.
ANONYMOUS_WRITE = getattr(django_settings, 'WIKI_ANONYMOUS_WRITE', False)
ANONYMOUS_WRITE = getattr(django_settings, "WIKI_ANONYMOUS_WRITE", False)

#: Globally enable create access for anonymous users.
#: Defaults to ``ANONYMOUS_WRITE``.
ANONYMOUS_CREATE = getattr(
    django_settings,
    'WIKI_ANONYMOUS_CREATE',
    ANONYMOUS_WRITE)
ANONYMOUS_CREATE = getattr(django_settings, "WIKI_ANONYMOUS_CREATE", ANONYMOUS_WRITE)

#: Default setting to allow anonymous users upload access. Used in
#: plugins.attachments and plugins.images, and can be overwritten in
#: these plugins.
ANONYMOUS_UPLOAD = getattr(django_settings, 'WIKI_ANONYMOUS_UPLOAD', False)
ANONYMOUS_UPLOAD = getattr(django_settings, "WIKI_ANONYMOUS_UPLOAD", False)

#: Sign up, login and logout views should be accessible.
ACCOUNT_HANDLING = getattr(django_settings, 'WIKI_ACCOUNT_HANDLING', True)
ACCOUNT_HANDLING = getattr(django_settings, "WIKI_ACCOUNT_HANDLING", True)

#: Signup allowed? If it's not allowed, logged in superusers can still access
#: the signup page to create new users.
ACCOUNT_SIGNUP_ALLOWED = ACCOUNT_HANDLING and getattr(
    django_settings, 'WIKI_ACCOUNT_SIGNUP_ALLOWED', True
    django_settings, "WIKI_ACCOUNT_SIGNUP_ALLOWED", True
)

if ACCOUNT_HANDLING:


@@ 264,72 235,59 @@ else:
#: Maximum amount of children to display in a menu before showing "+more".
#: NEVER set this to 0 as it will wrongly inform the user that there are no
#: children and for instance that an article can be safely deleted.
SHOW_MAX_CHILDREN = getattr(django_settings, 'WIKI_SHOW_MAX_CHILDREN', 20)
SHOW_MAX_CHILDREN = getattr(django_settings, "WIKI_SHOW_MAX_CHILDREN", 20)

#: User Bootstrap's select widget. Switch off if you're not using Bootstrap!
USE_BOOTSTRAP_SELECT_WIDGET = getattr(
    django_settings,
    'WIKI_USE_BOOTSTRAP_SELECT_WIDGET',
    True)
    django_settings, "WIKI_USE_BOOTSTRAP_SELECT_WIDGET", True
)

#: Dotted name of the class used to construct urlpatterns for the wiki.
#: Default is wiki.urls.WikiURLPatterns. To customize urls or view handlers,
#: you can derive from this.
URL_CONFIG_CLASS = getattr(
    django_settings,
    'WIKI_URL_CONFIG_CLASS',
    None)
URL_CONFIG_CLASS = getattr(django_settings, "WIKI_URL_CONFIG_CLASS", None)

#: Seconds of timeout before renewing the article cache. Articles are automatically
#: renewed whenever an edit occurs but article content may be generated from
#: other objects that are changed.
CACHE_TIMEOUT = getattr(django_settings, 'WIKI_CACHE_TIMEOUT', 600)
CACHE_TIMEOUT = getattr(django_settings, "WIKI_CACHE_TIMEOUT", 600)

#: Choose the Group model to use for permission handling. Defaults to django's auth.Group.
GROUP_MODEL = getattr(django_settings, 'WIKI_GROUP_MODEL', 'auth.Group')
GROUP_MODEL = getattr(django_settings, "WIKI_GROUP_MODEL", "auth.Group")

###################
# SPAM PROTECTION #
###################

#: Maximum allowed revisions per hour for any given user or IP.
REVISIONS_PER_HOUR = getattr(django_settings, 'WIKI_REVISIONS_PER_HOUR', 60)
REVISIONS_PER_HOUR = getattr(django_settings, "WIKI_REVISIONS_PER_HOUR", 60)

#: Maximum allowed revisions per minute for any given user or IP.
REVISIONS_PER_MINUTES = getattr(
    django_settings,
    'WIKI_REVISIONS_PER_MINUTES',
    5)
REVISIONS_PER_MINUTES = getattr(django_settings, "WIKI_REVISIONS_PER_MINUTES", 5)

#: Maximum allowed revisions per hour for any anonymous user and any IP.
REVISIONS_PER_HOUR_ANONYMOUS = getattr(
    django_settings,
    'WIKI_REVISIONS_PER_HOUR_ANONYMOUS',
    10)
    django_settings, "WIKI_REVISIONS_PER_HOUR_ANONYMOUS", 10
)

#: Maximum allowed revisions per minute for any anonymous user and any IP.
REVISIONS_PER_MINUTES_ANONYMOUS = getattr(
    django_settings,
    'WIKI_REVISIONS_PER_MINUTES_ANONYMOUS',
    2)
    django_settings, "WIKI_REVISIONS_PER_MINUTES_ANONYMOUS", 2
)

#: Number of minutes to look back for looking up ``REVISIONS_PER_MINUTES``
#: and ``REVISIONS_PER_MINUTES_ANONYMOUS``.
REVISIONS_MINUTES_LOOKBACK = getattr(
    django_settings,
    'WIKI_REVISIONS_MINUTES_LOOKBACK',
    2)
    django_settings, "WIKI_REVISIONS_MINUTES_LOOKBACK", 2
)

###########
# STORAGE #
###########

#: Default Django storage backend to use for images, attachments etc.
STORAGE_BACKEND = getattr(
    django_settings,
    'WIKI_STORAGE_BACKEND',
    default_storage)
STORAGE_BACKEND = getattr(django_settings, "WIKI_STORAGE_BACKEND", default_storage)

#: Use django-sendfile for sending out files? Otherwise the whole file is
#: first read into memory and than send with a mime type based on the file.
USE_SENDFILE = getattr(django_settings, 'WIKI_ATTACHMENTS_USE_SENDFILE', False)
USE_SENDFILE = getattr(django_settings, "WIKI_ATTACHMENTS_USE_SENDFILE", False)

M src/wiki/core/exceptions.py => src/wiki/core/exceptions.py +1 -1
@@ 1,10 1,10 @@

# If no root URL is found, we raise this...


class NoRootURL(Exception):
    pass


# If there is more than one...



M src/wiki/core/http.py => src/wiki/core/http.py +8 -5
@@ 11,6 11,7 @@ from wiki.conf import settings

def django_sendfile_response(request, filepath):
    from sendfile import sendfile

    return sendfile(request, filepath)




@@ 23,18 24,18 @@ def send_file(request, filepath, last_modified=None, filename=None):
    else:
        mimetype, encoding = mimetypes.guess_type(fullpath)

    mimetype = mimetype or 'application/octet-stream'
    mimetype = mimetype or "application/octet-stream"

    if settings.USE_SENDFILE:
        response = django_sendfile_response(request, filepath)
    else:
        response = HttpResponse(open(fullpath, 'rb').read(), content_type=mimetype)
        response = HttpResponse(open(fullpath, "rb").read(), content_type=mimetype)

    if not last_modified:
        response["Last-Modified"] = http_date(statobj.st_mtime)
    else:
        if isinstance(last_modified, datetime):
            last_modified = float(dateformat.format(last_modified, 'U'))
            last_modified = float(dateformat.format(last_modified, "U"))
        response["Last-Modified"] = http_date(epoch_seconds=last_modified)

    response["Content-Length"] = statobj.st_size


@@ 44,9 45,11 @@ def send_file(request, filepath, last_modified=None, filename=None):

    if filename:
        filename_escaped = filepath_to_uri(filename)
        if 'pdf' in mimetype.lower():
        if "pdf" in mimetype.lower():
            response["Content-Disposition"] = "inline; filename=%s" % filename_escaped
        else:
            response["Content-Disposition"] = "attachment; filename=%s" % filename_escaped
            response["Content-Disposition"] = (
                "attachment; filename=%s" % filename_escaped
            )

    return response

M src/wiki/core/markdown/__init__.py => src/wiki/core/markdown/__init__.py +9 -11
@@ 5,10 5,9 @@ from wiki.core.plugins import registry as plugin_registry


class ArticleMarkdown(markdown.Markdown):

    def __init__(self, article, preview=False, user=None, *args, **kwargs):
        kwargs.update(settings.MARKDOWN_KWARGS)
        kwargs['extensions'] = self.get_markdown_extensions()
        kwargs["extensions"] = self.get_markdown_extensions()
        super().__init__(*args, **kwargs)
        self.article = article
        self.preview = preview


@@ 17,13 16,13 @@ class ArticleMarkdown(markdown.Markdown):
    def core_extensions(self):
        """List of core extensions found in the mdx folder"""
        return [
            'wiki.core.markdown.mdx.codehilite',
            'wiki.core.markdown.mdx.previewlinks',
            'wiki.core.markdown.mdx.responsivetable',
            "wiki.core.markdown.mdx.codehilite",
            "wiki.core.markdown.mdx.previewlinks",
            "wiki.core.markdown.mdx.responsivetable",
        ]

    def get_markdown_extensions(self):
        extensions = list(settings.MARKDOWN_KWARGS.get('extensions', []))
        extensions = list(settings.MARKDOWN_KWARGS.get("extensions", []))
        extensions += self.core_extensions()
        extensions += plugin_registry.get_markdown_extensions()
        return extensions


@@ 31,17 30,16 @@ class ArticleMarkdown(markdown.Markdown):
    def convert(self, text, *args, **kwargs):
        html = super().convert(text, *args, **kwargs)
        if settings.MARKDOWN_SANITIZE_HTML:
            tags = settings.MARKDOWN_HTML_WHITELIST + plugin_registry.get_html_whitelist()
            tags = (
                settings.MARKDOWN_HTML_WHITELIST + plugin_registry.get_html_whitelist()
            )

            attrs = dict()
            attrs.update(settings.MARKDOWN_HTML_ATTRIBUTES)
            attrs.update(plugin_registry.get_html_attributes().items())

            html = bleach.clean(
                html,
                tags=tags,
                attributes=attrs,
                styles=settings.MARKDOWN_HTML_STYLES,
                html, tags=tags, attributes=attrs, styles=settings.MARKDOWN_HTML_STYLES,
            )
        return html


M src/wiki/core/markdown/mdx/codehilite.py => src/wiki/core/markdown/mdx/codehilite.py +24 -22
@@ 11,13 11,13 @@ logger = logging.getLogger(__name__)
def highlight(code, config, tab_length, lang=None):
    code = CodeHilite(
        code,
        linenums=config['linenums'],
        guess_lang=config['guess_lang'],
        css_class=config['css_class'],
        style=config['pygments_style'],
        noclasses=config['noclasses'],
        linenums=config["linenums"],
        guess_lang=config["guess_lang"],
        css_class=config["css_class"],
        style=config["pygments_style"],
        noclasses=config["noclasses"],
        tab_length=tab_length,
        use_pygments=config['use_pygments'],
        use_pygments=config["use_pygments"],
        lang=lang,
    )
    html = code.hilite()


@@ 31,15 31,19 @@ class WikiFencedBlockPreprocessor(Preprocessor):
    directly and without configuration options invoke the vanilla CodeHilite
    extension.
    """
    FENCED_BLOCK_RE = re.compile(r'''

    FENCED_BLOCK_RE = re.compile(
        r"""
(?P<fence>^(?:~{3,}|`{3,}))[ ]*         # Opening ``` or ~~~
(\{?\.?(?P<lang>[a-zA-Z0-9_+-]*))?[ ]*  # Optional {, and lang
# Optional highlight lines, single- or double-quote-delimited
(hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot))?[ ]*
}?[ ]*\n                                # Optional closing }
(?P<code>.*?)(?<=\n)
(?P=fence)[ ]*$''', re.MULTILINE | re.DOTALL | re.VERBOSE)
    CODE_WRAP = '<pre>%s</pre>'
(?P=fence)[ ]*$""",
        re.MULTILINE | re.DOTALL | re.VERBOSE,
    )
    CODE_WRAP = "<pre>%s</pre>"

    def __init__(self, md):
        super().__init__(md)


@@ 54,14 58,14 @@ class WikiFencedBlockPreprocessor(Preprocessor):
        while 1:
            m = self.FENCED_BLOCK_RE.search(text)
            if m:
                lang = ''
                if m.group('lang'):
                    lang = m.group('lang')
                html = highlight(m.group('code'), self.config, self.markdown.tab_length, lang=lang)
                lang = ""
                if m.group("lang"):
                    lang = m.group("lang")
                html = highlight(
                    m.group("code"), self.config, self.markdown.tab_length, lang=lang
                )
                placeholder = self.markdown.htmlStash.store(html)
                text = '%s\n%s\n%s' % (text[:m.start()],
                                       placeholder,
                                       text[m.end():])
                text = "%s\n%s\n%s" % (text[: m.start()], placeholder, text[m.end() :])
            else:
                break
        return text.split("\n")


@@ 72,16 76,16 @@ class HiliteTreeprocessor(Treeprocessor):

    def run(self, root):
        """ Find code blocks and store in htmlStash. """
        blocks = root.iter('pre')
        blocks = root.iter("pre")
        for block in blocks:
            if len(block) == 1 and block[0].tag == 'code':
            if len(block) == 1 and block[0].tag == "code":
                html = highlight(block[0].text, self.config, self.markdown.tab_length)
                placeholder = self.markdown.htmlStash.store(html)
                # Clear codeblock in etree instance
                block.clear()
                # Change to p element which will later
                # be removed when inserting raw html
                block.tag = 'p'
                block.tag = "p"
                block.text = placeholder




@@ 112,9 116,7 @@ class WikiCodeHiliteExtension(CodeHiliteExtension):
            del md.preprocessors["fenced_code_block"]
        hiliter = WikiFencedBlockPreprocessor(md)
        hiliter.config = self.getConfigs()
        md.preprocessors.add('fenced_code_block',
                             hiliter,
                             ">normalize_whitespace")
        md.preprocessors.add("fenced_code_block", hiliter, ">normalize_whitespace")

        md.registerExtension(self)


M src/wiki/core/markdown/mdx/previewlinks.py => src/wiki/core/markdown/mdx/previewlinks.py +4 -5
@@ 7,17 7,16 @@ class PreviewLinksExtension(markdown.Extension):
    """Markdown Extension that sets all anchor targets to _blank when in preview mode"""

    def extendMarkdown(self, md):
        md.treeprocessors.add('previewlinks', PreviewLinksTree(md), "_end")
        md.treeprocessors.add("previewlinks", PreviewLinksTree(md), "_end")


class PreviewLinksTree(Treeprocessor):

    def run(self, root):
        if self.md.preview:
            for a in root.findall('.//a'):
            for a in root.findall(".//a"):
                # Do not set target for links like href='#markdown'
                if not a.get('href').startswith('#'):
                    a.set('target', '_blank')
                if not a.get("href").startswith("#"):
                    a.set("target", "_blank")
        return root



M src/wiki/core/markdown/mdx/responsivetable.py => src/wiki/core/markdown/mdx/responsivetable.py +7 -7
@@ 7,12 7,12 @@ class ResponsiveTableExtension(markdown.Extension):
    """Wraps all tables with Bootstrap's table-responsive class"""

    def extendMarkdown(self, md):
        md.treeprocessors.add('responsivetable', ResponsiveTableTree(md), "_end")
        md.treeprocessors.add("responsivetable", ResponsiveTableTree(md), "_end")


class ResponsiveTableTree(Treeprocessor):
    def run(self, root):
        for table_wrapper in list(root.getiterator('table')):
        for table_wrapper in list(root.getiterator("table")):
            table_new = self.create_table_element()
            self.convert_to_wrapper(table_wrapper)
            self.move_children(table_wrapper, table_new)


@@ 21,9 21,9 @@ class ResponsiveTableTree(Treeprocessor):

    def create_table_element(self):
        """Create table element with text and tail"""
        element = etree.Element('table')
        element.text = '\n'
        element.tail = '\n'
        element = etree.Element("table")
        element.text = "\n"
        element.tail = "\n"
        return element

    def move_children(self, element1, element2):


@@ 35,8 35,8 @@ class ResponsiveTableTree(Treeprocessor):
            element1.remove(child)

    def convert_to_wrapper(self, element):
        element.tag = 'div'
        element.set('class', 'table-responsive')
        element.tag = "div"
        element.set("class", "table-responsive")


def makeExtension(*args, **kwargs):

M src/wiki/core/paginator.py => src/wiki/core/paginator.py +4 -3
@@ 2,12 2,11 @@ from django.core.paginator import Paginator


class WikiPaginator(Paginator):

    def __init__(self, *args, **kwargs):
        """
        :param side_pages: How many pages should be shown before and after the current page
        """
        self.side_pages = kwargs.pop('side_pages', 4)
        self.side_pages = kwargs.pop("side_pages", 4)
        super().__init__(*args, **kwargs)

    def page(self, number):


@@ 18,7 17,9 @@ class WikiPaginator(Paginator):
    @property
    def page_range(self):
        left = max(self.last_accessed_page_number - self.side_pages, 2)
        right = min(self.last_accessed_page_number + self.side_pages + 1, self.num_pages)
        right = min(
            self.last_accessed_page_number + self.side_pages + 1, self.num_pages
        )
        pages = []
        if self.num_pages > 0:
            pages = [1]

M src/wiki/core/permissions.py => src/wiki/core/permissions.py +10 -13
@@ 17,7 17,9 @@ def can_read(article, user):
        return settings.CAN_READ(article, user)
    else:
        # Deny reading access to deleted articles if user has no delete access
        article_is_deleted = article.current_revision and article.current_revision.deleted
        article_is_deleted = (
            article.current_revision and article.current_revision.deleted
        )
        if article_is_deleted and not article.can_delete(user):
            return False



@@ 31,8 33,7 @@ def can_read(article, user):
        if user == article.owner:
            return True
        if article.group_read:
            if article.group and user.groups.filter(
                    id=article.group.id).exists():
            if article.group and user.groups.filter(id=article.group.id).exists():
                return True
        if article.can_moderate(user):
            return True


@@ 52,8 53,7 @@ def can_write(article, user):
    if user == article.owner:
        return True
    if article.group_write:
        if article.group and user and user.groups.filter(
                id=article.group.id).exists():
        if article.group and user and user.groups.filter(id=article.group.id).exists():
            return True
    if article.can_moderate(user):
        return True


@@ 63,7 63,7 @@ def can_write(article, user):
def can_assign(article, user):
    if callable(settings.CAN_ASSIGN):
        return settings.CAN_ASSIGN(article, user)
    return not user.is_anonymous and user.has_perm('wiki.assign')
    return not user.is_anonymous and user.has_perm("wiki.assign")


def can_assign_owner(article, user):


@@ 75,11 75,8 @@ def can_assign_owner(article, user):
def can_change_permissions(article, user):
    if callable(settings.CAN_CHANGE_PERMISSIONS):
        return settings.CAN_CHANGE_PERMISSIONS(article, user)
    return (
        not user.is_anonymous and (
            article.owner == user or
            user.has_perm('wiki.assign')
        )
    return not user.is_anonymous and (
        article.owner == user or user.has_perm("wiki.assign")
    )




@@ 92,10 89,10 @@ def can_delete(article, user):
def can_moderate(article, user):
    if callable(settings.CAN_MODERATE):
        return settings.CAN_MODERATE(article, user)
    return not user.is_anonymous and user.has_perm('wiki.moderate')
    return not user.is_anonymous and user.has_perm("wiki.moderate")


def can_admin(article, user):
    if callable(settings.CAN_ADMIN):
        return settings.CAN_ADMIN(article, user)
    return not user.is_anonymous and user.has_perm('wiki.admin')
    return not user.is_anonymous and user.has_perm("wiki.admin")

M src/wiki/core/plugins/base.py => src/wiki/core/plugins/base.py +8 -7
@@ 16,6 16,7 @@ plugin's models.
class BasePlugin:

    """Plugins should inherit from this"""

    # Must fill in!
    slug = None



@@ 23,10 24,10 @@ class BasePlugin:
    settings_form = None  # A form class to add to the settings tab
    urlpatterns = {
        # General urlpatterns that will reside in /wiki/plugins/plugin-slug/...
        'root': [],
        "root": [],
        # urlpatterns that receive article_id or urlpath, i.e.
        # /wiki/ArticleName/plugin/plugin-slug/...
        'article': [],
        "article": [],
    }
    article_tab = None  # (_('Attachments'), "fa fa-file")
    article_view = None  # A view for article_id/plugin/slug/


@@ 50,17 51,17 @@ class BasePlugin:

class PluginSidebarFormMixin(forms.ModelForm):

    unsaved_article_title = forms.CharField(widget=forms.HiddenInput(),
                                            required=True)
    unsaved_article_content = forms.CharField(widget=forms.HiddenInput(),
                                              required=False)
    unsaved_article_title = forms.CharField(widget=forms.HiddenInput(), required=True)
    unsaved_article_content = forms.CharField(
        widget=forms.HiddenInput(), required=False
    )

    def get_usermessage(self):
        pass


class PluginSettingsFormMixin:
    settings_form_headline = _('Settings for plugin')
    settings_form_headline = _("Settings for plugin")
    settings_order = 1
    settings_write_access = False


M src/wiki/core/plugins/loader.py => src/wiki/core/plugins/loader.py +1 -1
@@ 2,4 2,4 @@ from django.utils.module_loading import autodiscover_modules


def load_wiki_plugins():
    autodiscover_modules('wiki_plugin')
    autodiscover_modules("wiki_plugin")

M src/wiki/core/plugins/registry.py => src/wiki/core/plugins/registry.py +8 -20
@@ 19,7 19,7 @@ def register(PluginClass):
    plugin = PluginClass()
    _cache[PluginClass] = plugin

    settings_form = getattr(PluginClass, 'settings_form', None)
    settings_form = getattr(PluginClass, "settings_form", None)
    if settings_form:
        if isinstance(settings_form, str):
            klassname = settings_form.split(".")[-1]


@@ 28,29 28,17 @@ def register(PluginClass):
            settings_form = getattr(form_module, klassname)
        _settings_forms.append(settings_form)

    if getattr(PluginClass, 'article_tab', None):
    if getattr(PluginClass, "article_tab", None):
        _article_tabs.append(plugin)

    if getattr(PluginClass, 'sidebar', None):
    if getattr(PluginClass, "sidebar", None):
        _sidebar.append(plugin)

    _markdown_extensions.extend(
        getattr(
            PluginClass,
            'markdown_extensions',
            []))

    _html_whitelist.extend(
        getattr(
            PluginClass,
            'html_whitelist',
            []))

    _html_attributes.update(
        getattr(
            PluginClass,
            'html_attributes',
            dict()))
    _markdown_extensions.extend(getattr(PluginClass, "markdown_extensions", []))

    _html_whitelist.extend(getattr(PluginClass, "html_whitelist", []))

    _html_attributes.update(getattr(PluginClass, "html_attributes", dict()))


def get_plugins():

M src/wiki/core/utils.py => src/wiki/core/utils.py +1 -1
@@ 7,5 7,5 @@ def object_to_json_response(obj, status=200):
    version of that object
    """
    return JsonResponse(
        data=obj, status=status, safe=False, json_dumps_params={'ensure_ascii': False},
        data=obj, status=status, safe=False, json_dumps_params={"ensure_ascii": False},
    )

M src/wiki/core/version.py => src/wiki/core/version.py +17 -14
@@ 46,14 46,14 @@ def get_version(version=None):

    major = get_major_version(version)

    sub = ''
    if version[3] == 'alpha' and version[4] == 0:
    sub = ""
    if version[3] == "alpha" and version[4] == 0:
        git_changeset = get_git_changeset()
        if git_changeset:
            sub = '.dev%s' % git_changeset
            sub = ".dev%s" % git_changeset

    elif version[3] != 'final':
        mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
    elif version[3] != "final":
        mapping = {"alpha": "a", "beta": "b", "rc": "c"}
        sub = mapping[version[3]] + str(version[4])

    return str(major + sub)


@@ 63,7 63,7 @@ def get_major_version(version=None):
    "Returns major version from VERSION."
    version = get_complete_version(version)
    parts = 2 if version[2] == 0 else 3
    major = '.'.join(str(x) for x in version[:parts])
    major = ".".join(str(x) for x in version[:parts])
    return major




@@ 75,17 75,17 @@ def get_complete_version(version=None):
        from wiki import VERSION as version
    else:
        assert len(version) == 5
        assert version[3] in ('alpha', 'beta', 'rc', 'final')
        assert version[3] in ("alpha", "beta", "rc", "final")

    return version


def get_docs_version(version=None):
    version = get_complete_version(version)
    if version[3] != 'final':
        return 'dev'
    if version[3] != "final":
        return "dev"
    else:
        return '%d.%d' % version[:2]
        return "%d.%d" % version[:2]


def get_git_changeset():


@@ 97,13 97,16 @@ def get_git_changeset():
    """
    repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    git_log = subprocess.Popen(
        'git log --pretty=format:%ct --quiet -1 HEAD',
        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        shell=True, cwd=repo_dir, universal_newlines=True
        "git log --pretty=format:%ct --quiet -1 HEAD",
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        shell=True,
        cwd=repo_dir,
        universal_newlines=True,
    )
    timestamp = git_log.communicate()[0]
    try:
        timestamp = datetime.datetime.utcfromtimestamp(int(timestamp))
    except ValueError:
        return None
    return timestamp.strftime('%Y%m%d%H%M%S')
    return timestamp.strftime("%Y%m%d%H%M%S")

M src/wiki/decorators.py => src/wiki/decorators.py +48 -35
@@ 1,6 1,10 @@
from functools import wraps

from django.http import HttpResponseForbidden, HttpResponseNotFound, HttpResponseRedirect
from django.http import (
    HttpResponseForbidden,
    HttpResponseNotFound,
    HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.urls import reverse


@@ 11,31 15,37 @@ from wiki.core.exceptions import NoRootURL

def response_forbidden(request, article, urlpath, read_denied=False):
    if request.user.is_anonymous:
        qs = request.META.get('QUERY_STRING', '')
        qs = request.META.get("QUERY_STRING", "")
        if qs:
            qs = urlquote('?' + qs)
            qs = urlquote("?" + qs)
        else:
            qs = ''
            qs = ""
        return redirect(settings.LOGIN_URL + "?next=" + request.path + qs)
    else:
        return HttpResponseForbidden(
            render_to_string(
                "wiki/permission_denied.html",
                context={
                    'article': article,
                    'urlpath': urlpath,
                    'read_denied': read_denied
                    "article": article,
                    "urlpath": urlpath,
                    "read_denied": read_denied,
                },
                request=request
                request=request,
            )
        )


# TODO: This decorator is too complex (C901)
def get_article(func=None, can_read=True, can_write=False,  # noqa: max-complexity=13
                deleted_contents=False, not_locked=False,
                can_delete=False, can_moderate=False,
                can_create=False):
def get_article(
    func=None,
    can_read=True,
    can_write=False,  # noqa: max-complexity=13
    deleted_contents=False,
    not_locked=False,
    can_delete=False,
    can_moderate=False,
    can_create=False,
):
    """View decorator for processing standard url keyword args: Intercepts the
    keyword args path or article_id and looks up an article, calling the decorated
    func with this ID.


@@ 64,35 74,32 @@ def get_article(func=None, can_read=True, can_write=False,  # noqa: max-complexi
    def wrapper(request, *args, **kwargs):
        from . import models

        path = kwargs.pop('path', None)
        article_id = kwargs.pop('article_id', None)
        path = kwargs.pop("path", None)
        article_id = kwargs.pop("article_id", None)

        # fetch by urlpath.path
        if path is not None:
            try:
                urlpath = models.URLPath.get_by_path(path, select_related=True)
            except NoRootURL:
                return redirect('wiki:root_create')
                return redirect("wiki:root_create")
            except models.URLPath.DoesNotExist:
                try:
                    pathlist = list(
                        filter(
                            lambda x: x != "",
                            path.split("/"),
                        ))
                    pathlist = list(filter(lambda x: x != "", path.split("/"),))
                    path = "/".join(pathlist[:-1])
                    parent = models.URLPath.get_by_path(path)
                    return HttpResponseRedirect(
                        reverse(
                            "wiki:create", kwargs={'path': parent.path, }) + "?slug=%s" % pathlist[-1].lower())
                        reverse("wiki:create", kwargs={"path": parent.path,})
                        + "?slug=%s" % pathlist[-1].lower()
                    )
                except models.URLPath.DoesNotExist:
                    return HttpResponseNotFound(
                        render_to_string(
                            "wiki/error.html",
                            context={
                                'error_type': 'ancestors_missing'
                            },
                            request=request))
                            context={"error_type": "ancestors_missing"},
                            request=request,
                        )
                    )
            if urlpath.article:
                # urlpath is already smart about prefetching items on article
                # (like current_revision), so we don't have to


@@ 100,7 107,7 @@ def get_article(func=None, can_read=True, can_write=False,  # noqa: max-complexi
            else:
                # Be robust: Somehow article is gone but urlpath exists...
                # clean up
                return_url = reverse('wiki:get', kwargs={'path': urlpath.parent.path})
                return_url = reverse("wiki:get", kwargs={"path": urlpath.parent.path})
                urlpath.delete()
                return HttpResponseRedirect(return_url)



@@ 114,20 121,23 @@ def get_article(func=None, can_read=True, can_write=False,  # noqa: max-complexi
            article = get_object_or_404(articles, id=article_id)
            try:
                urlpath = models.URLPath.objects.get(articles__article=article)
            except (models.URLPath.DoesNotExist, models.URLPath.MultipleObjectsReturned):
            except (
                models.URLPath.DoesNotExist,
                models.URLPath.MultipleObjectsReturned,
            ):
                urlpath = None

        else:
            raise TypeError('You should specify either article_id or path')
            raise TypeError("You should specify either article_id or path")

        if not deleted_contents:
            # If the article has been deleted, show a special page.
            if urlpath:
                if urlpath.is_deleted():  # This also checks all ancestors
                    return redirect('wiki:deleted', path=urlpath.path)
                    return redirect("wiki:deleted", path=urlpath.path)
            else:
                if article.current_revision and article.current_revision.deleted:
                    return redirect('wiki:deleted', article_id=article.id)
                    return redirect("wiki:deleted", article_id=article.id)

        if article.current_revision.locked and not_locked:
            return response_forbidden(request, article, urlpath)


@@ 139,7 149,8 @@ def get_article(func=None, can_read=True, can_write=False,  # noqa: max-complexi
            return response_forbidden(request, article, urlpath)

        if can_create and not (
                request.user.is_authenticated or settings.ANONYMOUS_CREATE):
            request.user.is_authenticated or settings.ANONYMOUS_CREATE
        ):
            return response_forbidden(request, article, urlpath)

        if can_delete and not article.can_delete(request.user):


@@ 148,7 159,7 @@ def get_article(func=None, can_read=True, can_write=False,  # noqa: max-complexi
        if can_moderate and not article.can_moderate(request.user):
            return response_forbidden(request, article, urlpath)

        kwargs['urlpath'] = urlpath
        kwargs["urlpath"] = urlpath

        return func(request, article, *args, **kwargs)



@@ 163,7 174,8 @@ def get_article(func=None, can_read=True, can_write=False,  # noqa: max-complexi
            not_locked=not_locked,
            can_delete=can_delete,
            can_moderate=can_moderate,
            can_create=can_create)
            can_create=can_create,
        )


def disable_signal_for_loaddata(signal_handler):


@@ 173,7 185,8 @@ def disable_signal_for_loaddata(signal_handler):

    @wraps(signal_handler)
    def wrapper(*args, **kwargs):
        if kwargs.get('raw', False):
        if kwargs.get("raw", False):
            return
        return signal_handler(*args, **kwargs)

    return wrapper

M src/wiki/editors/base.py => src/wiki/editors/base.py +1 -1
@@ 7,7 7,7 @@ class BaseEditor:

    # The editor id can be used for conditional testing. If you write your
    # own editor class, you can use the same editor_id as some editor
    editor_id = 'plaintext'
    editor_id = "plaintext"
    media_admin = ()
    media_frontend = ()


M src/wiki/editors/markitup.py => src/wiki/editors/markitup.py +22 -16
@@ 8,9 8,9 @@ class MarkItUpWidget(forms.Widget):
    def __init__(self, attrs=None):
        # The 'rows' and 'cols' attributes are required for HTML correctness.
        default_attrs = {
            'class': 'markItUp',
            'rows': '10',
            'cols': '40',
            "class": "markItUp",
            "rows": "10",
            "cols": "40",
        }
        if attrs:
            default_attrs.update(attrs)


@@ 24,7 24,7 @@ class MarkItUpAdminWidget(MarkItUpWidget):


class MarkItUp(BaseEditor):
    editor_id = 'markitup'
    editor_id = "markitup"

    def get_admin_widget(self, instance=None):
        return MarkItUpAdminWidget()


@@ 34,20 34,26 @@ class MarkItUp(BaseEditor):

    class AdminMedia:
        css = {
            'all': ("wiki/markitup/skins/simple/style.css",
                    "wiki/markitup/sets/admin/style.css",)
            "all": (
                "wiki/markitup/skins/simple/style.css",
                "wiki/markitup/sets/admin/style.css",
            )
        }
        js = ("wiki/markitup/admin.init.js",
              "wiki/markitup/jquery.markitup.js",
              "wiki/markitup/sets/admin/set.js",
              )
        js = (
            "wiki/markitup/admin.init.js",
            "wiki/markitup/jquery.markitup.js",
            "wiki/markitup/sets/admin/set.js",
        )

    class Media:
        css = {
            'all': ("wiki/markitup/skins/simple/style.css",
                    "wiki/markitup/sets/frontend/style.css",)
            "all": (
                "wiki/markitup/skins/simple/style.css",
                "wiki/markitup/sets/frontend/style.css",
            )
        }
        js = ("wiki/markitup/frontend.init.js",
              "wiki/markitup/jquery.markitup.js",
              "wiki/markitup/sets/frontend/set.js",
              )
        js = (
            "wiki/markitup/frontend.init.js",
            "wiki/markitup/jquery.markitup.js",
            "wiki/markitup/sets/frontend/set.js",
        )

M src/wiki/forms.py => src/wiki/forms.py +238 -202
@@ 1,19 1,18 @@

__all__ = [
    'UserCreationForm',
    'UserUpdateForm',
    'WikiSlugField',
    'SpamProtectionMixin',
    'CreateRootForm',
    'MoveForm',
    'EditForm',
    'SelectWidgetBootstrap',
    'TextInputPrepend',
    'CreateForm',
    'DeleteForm',
    'PermissionsForm',
    'DirFilterForm',
    'SearchForm',
    "UserCreationForm",
    "UserUpdateForm",
    "WikiSlugField",
    "SpamProtectionMixin",
    "CreateRootForm",
    "MoveForm",
    "EditForm",
    "SelectWidgetBootstrap",
    "TextInputPrepend",
    "CreateForm",
    "DeleteForm",
    "PermissionsForm",
    "DirFilterForm",
    "SearchForm",
]

from datetime import timedelta


@@ 39,10 38,10 @@ from wiki.editors import getEditor
from .forms_account_handling import UserCreationForm, UserUpdateForm

validate_slug_numbers = RegexValidator(
    r'^[0-9]+$',
    r"^[0-9]+$",
    _("A 'slug' cannot consist solely of numbers."),
    'invalid',
    inverse_match=True
    "invalid",
    inverse_match=True,
)




@@ 55,50 54,48 @@ class WikiSlugField(forms.CharField):
    default_validators = [validators.validate_slug, validate_slug_numbers]

    def __init__(self, *args, **kwargs):
        self.allow_unicode = kwargs.pop('allow_unicode', False)
        self.allow_unicode = kwargs.pop("allow_unicode", False)
        if self.allow_unicode:
            self.default_validators = [
                validators.validate_unicode_slug,
                validate_slug_numbers
                validate_slug_numbers,
            ]
        super().__init__(*args, **kwargs)


def _clean_slug(slug, urlpath):
    if slug.startswith("_"):
        raise forms.ValidationError(
            gettext('A slug may not begin with an underscore.'))
    if slug == 'admin':
        raise forms.ValidationError(
            gettext("'admin' is not a permitted slug name."))
        raise forms.ValidationError(gettext("A slug may not begin with an underscore."))
    if slug == "admin":
        raise forms.ValidationError(gettext("'admin' is not a permitted slug name."))

    if settings.URL_CASE_SENSITIVE:
        already_existing_slug = models.URLPath.objects.filter(
            slug=slug,
            parent=urlpath)
        already_existing_slug = models.URLPath.objects.filter(slug=slug, parent=urlpath)
    else:
        slug = slug.lower()
        already_existing_slug = models.URLPath.objects.filter(
            slug__iexact=slug,
            parent=urlpath)
            slug__iexact=slug, parent=urlpath
        )
    if already_existing_slug:
        already_urlpath = already_existing_slug[0]
        if already_urlpath.article and already_urlpath.article.current_revision.deleted:
            raise forms.ValidationError(
                gettext('A deleted article with slug "%s" already exists.') %
                already_urlpath.slug)
                gettext('A deleted article with slug "%s" already exists.')
                % already_urlpath.slug
            )
        else:
            raise forms.ValidationError(
                gettext('A slug named "%s" already exists.') %
                already_urlpath.slug)
                gettext('A slug named "%s" already exists.') % already_urlpath.slug
            )

    if settings.CHECK_SLUG_URL_AVAILABLE:
        try:
            # Fail validation if URL resolves to non-wiki app
            match = resolve(urlpath.path + '/' + slug + '/')
            if match.app_name != 'wiki':
            match = resolve(urlpath.path + "/" + slug + "/")
            if match.app_name != "wiki":
                raise forms.ValidationError(
                    gettext('This slug conflicts with an existing URL.'))
                    gettext("This slug conflicts with an existing URL.")
                )
        except Resolver404:
            pass



@@ 128,19 125,20 @@ class SpamProtectionMixin:
        if request.user.is_authenticated:
            user = request.user
        else:
            ip_address = request.META.get('REMOTE_ADDR', None)
            ip_address = request.META.get("REMOTE_ADDR", None)

        if not (user or ip_address):
            raise forms.ValidationError(
                gettext(
                    'Spam protection failed to find both a logged in user and an IP address.'))
                    "Spam protection failed to find both a logged in user and an IP address."
                )
            )

        def check_interval(from_time, max_count, interval_name):
            from_time = timezone.now(
            ) - timedelta(minutes=settings.REVISIONS_MINUTES_LOOKBACK)
            revisions = self.revision_model.objects.filter(
                created__gte=from_time,
            from_time = timezone.now() - timedelta(
                minutes=settings.REVISIONS_MINUTES_LOOKBACK
            )
            revisions = self.revision_model.objects.filter(created__gte=from_time,)
            if user:
                revisions = revisions.filter(user=user)
            if ip_address:


@@ 148,17 146,20 @@ class SpamProtectionMixin:
            revisions = revisions.count()
            if revisions >= max_count:
                raise forms.ValidationError(
                    gettext('Spam protection: You are only allowed to create or edit %(revisions)d article(s) per %(interval_name)s.') % {
                        'revisions': max_count,
                        'interval_name': interval_name,
                    })
                    gettext(
                        "Spam protection: You are only allowed to create or edit %(revisions)d article(s) per %(interval_name)s."
                    )
                    % {"revisions": max_count, "interval_name": interval_name,}
                )

        if not settings.LOG_IPS_ANONYMOUS:
            return
        if request.user.has_perm('wiki.moderator'):
        if request.user.has_perm("wiki.moderator"):
            return

        from_time = timezone.now() - timedelta(minutes=settings.REVISIONS_MINUTES_LOOKBACK)
        from_time = timezone.now() - timedelta(
            minutes=settings.REVISIONS_MINUTES_LOOKBACK
        )
        if request.user.is_authenticated:
            per_minute = settings.REVISIONS_PER_MINUTES
        else:


@@ 166,9 167,9 @@ class SpamProtectionMixin:
        check_interval(
            from_time,
            per_minute,
            _('minute') if settings.REVISIONS_MINUTES_LOOKBACK == 1 else (
                _('%d minutes') %
                settings.REVISIONS_MINUTES_LOOKBACK),
            _("minute")
            if settings.REVISIONS_MINUTES_LOOKBACK == 1
            else (_("%d minutes") % settings.REVISIONS_MINUTES_LOOKBACK),
        )

        from_time = timezone.now() - timedelta(minutes=60)


@@ 176,75 177,85 @@ class SpamProtectionMixin:
            per_hour = settings.REVISIONS_PER_MINUTES
        else:
            per_hour = settings.REVISIONS_PER_MINUTES_ANONYMOUS
        check_interval(from_time, per_hour, _('hour'))
        check_interval(from_time, per_hour, _("hour"))


class CreateRootForm(forms.Form):

    title = forms.CharField(
        label=_('Title'),
        label=_("Title"),
        help_text=_(
            'Initial title of the article. May be overridden with revision titles.'))
            "Initial title of the article. May be overridden with revision titles."
        ),
    )
    content = forms.CharField(
        label=_('Type in some contents'),
        label=_("Type in some contents"),
        help_text=_(
            'This is just the initial contents of your article. After creating it, you can use more complex features like adding plugins, meta data, related articles etc...'),
        required=False, widget=getEditor().get_widget())  # @UndefinedVariable
            "This is just the initial contents of your article. After creating it, you can use more complex features like adding plugins, meta data, related articles etc..."
        ),
        required=False,
        widget=getEditor().get_widget(),
    )  # @UndefinedVariable


class MoveForm(forms.Form):

    destination = forms.CharField(label=_('Destination'))
    destination = forms.CharField(label=_("Destination"))
    slug = WikiSlugField(max_length=models.URLPath.SLUG_MAX_LENGTH)
    redirect = forms.BooleanField(label=_('Redirect pages'),
                                  help_text=_('Create a redirect page for every moved article?'),
                                  required=False)
    redirect = forms.BooleanField(
        label=_("Redirect pages"),
        help_text=_("Create a redirect page for every moved article?"),
        required=False,
    )

    def clean(self):
        cd = super().clean()
        if cd.get('slug'):
            dest_path = get_object_or_404(models.URLPath, pk=self.cleaned_data['destination'])
            cd['slug'] = _clean_slug(cd['slug'], dest_path)
        if cd.get("slug"):
            dest_path = get_object_or_404(
                models.URLPath, pk=self.cleaned_data["destination"]
            )
            cd["slug"] = _clean_slug(cd["slug"], dest_path)
        return cd


class EditForm(forms.Form, SpamProtectionMixin):

    title = forms.CharField(label=_('Title'),)
    title = forms.CharField(label=_("Title"),)
    content = forms.CharField(
        label=_('Contents'),
        required=False,
        widget=getEditor().get_widget())  # @UndefinedVariable
        label=_("Contents"), required=False, widget=getEditor().get_widget()
    )  # @UndefinedVariable

    summary = forms.CharField(
        label=pgettext_lazy('Revision comment', 'Summary'),
        label=pgettext_lazy("Revision comment", "Summary"),
        help_text=_(
            'Give a short reason for your edit, which will be stated in the revision log.'),
        required=False)

    current_revision = forms.IntegerField(
            "Give a short reason for your edit, which will be stated in the revision log."
        ),
        required=False,
        widget=forms.HiddenInput())
    )

    current_revision = forms.IntegerField(required=False, widget=forms.HiddenInput())

    def __init__(self, request, current_revision, *args, **kwargs):

        self.request = request
        self.no_clean = kwargs.pop('no_clean', False)
        self.preview = kwargs.pop('preview', False)
        self.no_clean = kwargs.pop("no_clean", False)
        self.preview = kwargs.pop("preview", False)
        self.initial_revision = current_revision
        self.presumed_revision = None
        if current_revision:
            # For e.g. editing a section of the text: The content provided by the caller is used.
            #      Otherwise use the content of the revision.
            provided_content = True
            content = kwargs.pop('content', None)
            content = kwargs.pop("content", None)
            if content is None:
                provided_content = False
                content = current_revision.content
            initial = {'content': content,
                       'title': current_revision.title,
                       'current_revision': current_revision.id}
            initial.update(kwargs.get('initial', {}))
            initial = {
                "content": content,
                "title": current_revision.title,
                "current_revision": current_revision.id,
            }
            initial.update(kwargs.get("initial", {}))

            # Manipulate any data put in args[0] such that the current_revision
            # is reset to match the actual current revision.


@@ 253,34 264,38 @@ class EditForm(forms.Form, SpamProtectionMixin):
                data = args[0]
                args = args[1:]
            if data is None:
                data = kwargs.get('data', None)
                data = kwargs.get("data", None)
            if data:
                self.presumed_revision = data.get('current_revision', None)
                self.presumed_revision = data.get("current_revision", None)
                if not str(self.presumed_revision) == str(self.initial_revision.id):
                    newdata = {}
                    for k, v in data.items():
                        newdata[k] = v
                    newdata['current_revision'] = self.initial_revision.id
                    newdata["current_revision"] = self.initial_revision.id
                    # Don't merge if content comes from the caller
                    if provided_content:
                        self.presumed_revision = self.initial_revision.id
                    else:
                        newdata['content'] = simple_merge(content, data.get('content', ""))
                    newdata['title'] = current_revision.title
                    kwargs['data'] = newdata
                        newdata["content"] = simple_merge(
                            content, data.get("content", "")
                        )
                    newdata["title"] = current_revision.title
                    kwargs["data"] = newdata
                else:
                    # Always pass as kwarg
                    kwargs['data'] = data
                    kwargs["data"] = data

            kwargs['initial'] = initial
            kwargs["initial"] = initial

        super().__init__(*args, **kwargs)

    def clean_title(self):
        title = self.cleaned_data.get('title', None)
        title = self.cleaned_data.get("title", None)
        title = (title or "").strip()
        if not title:
            raise forms.ValidationError(gettext('Article is missing title or has an invalid title'))
            raise forms.ValidationError(
                gettext("Article is missing title or has an invalid title")
            )
        return title

    def clean(self):


@@ 293,11 308,15 @@ class EditForm(forms.Form, SpamProtectionMixin):
        if not str(self.initial_revision.id) == str(self.presumed_revision):
            raise forms.ValidationError(
                gettext(
                    'While you were editing, someone else changed the revision. Your contents have been automatically merged with the new contents. Please review the text below.'))
        if ('title' in self.cleaned_data and
                self.cleaned_data['title'] == self.initial_revision.title and
                self.cleaned_data['content'] == self.initial_revision.content):
            raise forms.ValidationError(gettext('No changes made. Nothing to save.'))
                    "While you were editing, someone else changed the revision. Your contents have been automatically merged with the new contents. Please review the text below."
                )
            )
        if (
            "title" in self.cleaned_data
            and self.cleaned_data["title"] == self.initial_revision.title
            and self.cleaned_data["content"] == self.initial_revision.content
        ):
            raise forms.ValidationError(gettext("No changes made. Nothing to save."))
        self.check_spam()
        return self.cleaned_data



@@ 312,21 331,21 @@ class SelectWidgetBootstrap(forms.Select):
    option_template_name = "wiki/forms/select_option.html"

    def __init__(self, attrs={}, choices=(), disabled=False):
        attrs['class'] = 'btn-group pull-left btn-group-form'
        attrs["class"] = "btn-group pull-left btn-group-form"
        self.disabled = disabled
        self.noscript_widget = forms.Select(attrs={}, choices=choices)
        super().__init__(attrs, choices)

    def __setattr__(self, k, value):
        super().__setattr__(k, value)
        if k not in ('attrs', 'disabled'):
        if k not in ("attrs", "disabled"):
            self.noscript_widget.__setattr__(k, value)

    def get_context(self, name, value, attrs):
        context = super().get_context(name, value, attrs)
        context['label'] = _('Select an option')
        context['noscript'] = self.noscript_widget.render(name, value, {})
        context['disabled'] = ' disabled' if self.disabled else ''
        context["label"] = _("Select an option")
        context["noscript"] = self.noscript_widget.render(name, value, {})
        context["disabled"] = " disabled" if self.disabled else ""
        return context

    class Media(forms.Media):


@@ 338,40 357,41 @@ class TextInputPrepend(forms.TextInput):
    template_name = "wiki/forms/text.html"

    def __init__(self, *args, **kwargs):
        self.prepend = kwargs.pop('prepend', "")
        self.prepend = kwargs.pop("prepend", "")
        super().__init__(*args, **kwargs)

    def get_context(self, name, value, attrs):
        context = super().get_context(name, value, attrs)
        context['prepend'] = mark_safe(self.prepend)
        context["prepend"] = mark_safe(self.prepend)
        return context


class CreateForm(forms.Form, SpamProtectionMixin):

    def __init__(self, request, urlpath_parent, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.request = request
        self.urlpath_parent = urlpath_parent

    title = forms.CharField(label=_('Title'),)
    title = forms.CharField(label=_("Title"),)
    slug = WikiSlugField(
        label=_('Slug'),
        label=_("Slug"),
        help_text=_(
            "This will be the address where your article can be found. Use only alphanumeric characters and - or _.<br>Note: If you change the slug later on, links pointing to this article are <b>not</b> updated."),
        max_length=models.URLPath.SLUG_MAX_LENGTH)
            "This will be the address where your article can be found. Use only alphanumeric characters and - or _.<br>Note: If you change the slug later on, links pointing to this article are <b>not</b> updated."
        ),
        max_length=models.URLPath.SLUG_MAX_LENGTH,
    )
    content = forms.CharField(
        label=_('Contents'),
        required=False,
        widget=getEditor().get_widget())  # @UndefinedVariable
        label=_("Contents"), required=False, widget=getEditor().get_widget()
    )  # @UndefinedVariable

    summary = forms.CharField(
        label=pgettext_lazy('Revision comment', 'Summary'),
        label=pgettext_lazy("Revision comment", "Summary"),
        help_text=_("Write a brief message for the article's history log."),
        required=False)
        required=False,
    )

    def clean_slug(self):
        return _clean_slug(self.cleaned_data['slug'], self.urlpath_parent)
        return _clean_slug(self.cleaned_data["slug"], self.urlpath_parent)

    def clean(self):
        self.check_spam()


@@ 379,80 399,95 @@ class CreateForm(forms.Form, SpamProtectionMixin):


class DeleteForm(forms.Form):

    def __init__(self, *args, **kwargs):
        self.article = kwargs.pop('article')
        self.has_children = kwargs.pop('has_children')
        self.article = kwargs.pop("article")
        self.has_children = kwargs.pop("has_children")
        super().__init__(*args, **kwargs)

    confirm = forms.BooleanField(required=False, label=_('Yes, I am sure'))
    confirm = forms.BooleanField(required=False, label=_("Yes, I am sure"))
    purge = forms.BooleanField(
        widget=HiddenInput(),
        required=False, label=_('Purge'),
        required=False,
        label=_("Purge"),
        help_text=_(
            'Purge the article: Completely remove it (and all its contents) with no undo. Purging is a good idea if you want to free the slug such that users can create new articles in its place.'))
    revision = forms.ModelChoiceField(models.ArticleRevision.objects.all(),
                                      widget=HiddenInput(), required=False)
            "Purge the article: Completely remove it (and all its contents) with no undo. Purging is a good idea if you want to free the slug such that users can create new articles in its place."
        ),
    )
    revision = forms.ModelChoiceField(
        models.ArticleRevision.objects.all(), widget=HiddenInput(), required=False
    )

    def clean(self):
        if not self.cleaned_data['confirm']:
            raise forms.ValidationError(gettext('You are not sure enough!'))
        if self.cleaned_data['revision'] != self.article.current_revision:
        if not self.cleaned_data["confirm"]:
            raise forms.ValidationError(gettext("You are not sure enough!"))
        if self.cleaned_data["revision"] != self.article.current_revision:
            raise forms.ValidationError(
                gettext('While you tried to delete this article, it was modified. TAKE CARE!'))
                gettext(
                    "While you tried to delete this article, it was modified. TAKE CARE!"
                )
            )
        return self.cleaned_data


class PermissionsForm(PluginSettingsFormMixin, forms.ModelForm):

    locked = forms.BooleanField(
        label=_('Lock article'),
        help_text=_('Deny all users access to edit this article.'),
        required=False)
        label=_("Lock article"),
        help_text=_("Deny all users access to edit this article."),
        required=False,
    )

    settings_form_headline = _('Permissions')
    settings_form_headline = _("Permissions")
    settings_order = 5
    settings_write_access = False

    owner_username = forms.CharField(
        required=False,
        label=_('Owner'),
        help_text=_('Enter the username of the owner.'))
        label=_("Owner"),
        help_text=_("Enter the username of the owner."),
    )
    group = forms.ModelChoiceField(
        Group.objects.all(),
        empty_label=_('(none)'),
        label=_('Group'),
        required=False)
        Group.objects.all(), empty_label=_("(none)"), label=_("Group"), required=False
    )
    if settings.USE_BOOTSTRAP_SELECT_WIDGET:
        group.widget = SelectWidgetBootstrap()

    recursive = forms.BooleanField(
        label=_('Inherit permissions'),
        help_text=_('Check here to apply the above permissions (excluding group and owner of the article) recursively to articles below this one.'),
        required=False)
        label=_("Inherit permissions"),
        help_text=_(
            "Check here to apply the above permissions (excluding group and owner of the article) recursively to articles below this one."
        ),
        required=False,
    )

    recursive_owner = forms.BooleanField(
        label=_('Inherit owner'),
        help_text=_('Check here to apply the ownership setting recursively to articles below this one.'),
        required=False)
        label=_("Inherit owner"),
        help_text=_(
            "Check here to apply the ownership setting recursively to articles below this one."
        ),
        required=False,
    )

    recursive_group = forms.BooleanField(
        label=_('Inherit group'),
        help_text=_('Check here to apply the group setting recursively to articles below this one.'),
        required=False)
        label=_("Inherit group"),
        help_text=_(
            "Check here to apply the group setting recursively to articles below this one."
        ),
        required=False,
    )

    def get_usermessage(self):
        if self.changed_data:
            return _('Permission settings for the article were updated.')
            return _("Permission settings for the article were updated.")
        else:
            return _('Your permission settings were unchanged, so nothing saved.')
            return _("Your permission settings were unchanged, so nothing saved.")

    def __init__(self, article, request, *args, **kwargs):
        self.article = article
        self.user = request.user
        self.request = request
        kwargs['instance'] = article
        kwargs['initial'] = {'locked': article.current_revision.locked}
        kwargs["instance"] = article
        kwargs["initial"] = {"locked": article.current_revision.locked}

        super().__init__(*args, **kwargs)



@@ 462,48 497,49 @@ class PermissionsForm(PluginSettingsFormMixin, forms.ModelForm):
        if permissions.can_assign(article, request.user):
            self.can_assign = True
            self.can_change_groups = True
            self.fields['group'].queryset = Group.objects.all()
            self.fields["group"].queryset = Group.objects.all()
        elif permissions.can_assign_owner(article, request.user):
            self.fields['group'].queryset = Group.objects.filter(
                user=request.user)
            self.fields["group"].queryset = Group.objects.filter(user=request.user)
            self.can_change_groups = True
        else:
            # Quick-fix...
            # Set the group dropdown to readonly and with the current
            # group as only selectable option
            self.fields['group'] = forms.ModelChoiceField(
                queryset=Group.objects.filter(
                    id=self.instance.group.id)
                if self.instance.group else Group.objects.none(),
                empty_label=_('(none)'),
                required=False, widget=SelectWidgetBootstrap(
                    disabled=True)
                if settings.USE_BOOTSTRAP_SELECT_WIDGET else forms.Select(
                    attrs={'disabled': True}))
            self.fields['group_read'].widget = forms.HiddenInput()
            self.fields['group_write'].widget = forms.HiddenInput()
            self.fields["group"] = forms.ModelChoiceField(
                queryset=Group.objects.filter(id=self.instance.group.id)
                if self.instance.group
                else Group.objects.none(),
                empty_label=_("(none)"),
                required=False,
                widget=SelectWidgetBootstrap(disabled=True)
                if settings.USE_BOOTSTRAP_SELECT_WIDGET
                else forms.Select(attrs={"disabled": True}),
            )
            self.fields["group_read"].widget = forms.HiddenInput()
            self.fields["group_write"].widget = forms.HiddenInput()

        if not self.can_assign:
            self.fields['owner_username'].widget = forms.TextInput(attrs={'readonly': 'true'})
            self.fields['recursive'].widget = forms.HiddenInput()
            self.fields['recursive_group'].widget = forms.HiddenInput()
            self.fields['recursive_owner'].widget = forms.HiddenInput()
            self.fields['locked'].widget = forms.HiddenInput()
            self.fields["owner_username"].widget = forms.TextInput(
                attrs={"readonly": "true"}
            )
            self.fields["recursive"].widget = forms.HiddenInput()
            self.fields["recursive_group"].widget = forms.HiddenInput()
            self.fields["recursive_owner"].widget = forms.HiddenInput()
            self.fields["locked"].widget = forms.HiddenInput()

        self.fields['owner_username'].initial = getattr(
            article.owner,
            User.USERNAME_FIELD) if article.owner else ""
        self.fields["owner_username"].initial = (
            getattr(article.owner, User.USERNAME_FIELD) if article.owner else ""
        )

    def clean_owner_username(self):
        if self.can_assign:
            username = self.cleaned_data['owner_username']
            username = self.cleaned_data["owner_username"]
            if username:
                try:
                    kwargs = {User.USERNAME_FIELD: username}
                    user = User.objects.get(**kwargs)
                except User.DoesNotExist:
                    raise forms.ValidationError(
                        gettext('No user with that username'))
                    raise forms.ValidationError(gettext("No user with that username"))
            else:
                user = None
        else:


@@ 516,7 552,7 @@ class PermissionsForm(PluginSettingsFormMixin, forms.ModelForm):
        # Alter the owner according to the form field owner_username
        # TODO: Why not rename this field to 'owner' so this happens
        # automatically?
        article.owner = self.cleaned_data['owner_username']
        article.owner = self.cleaned_data["owner_username"]

        # Revert any changes to group permissions if the
        # current user is not allowed (see __init__)


@@ 527,25 563,24 @@ class PermissionsForm(PluginSettingsFormMixin, forms.ModelForm):
            article.group_write = self.article.group_write

        if self.can_assign:
            if self.cleaned_data['recursive']:
            if self.cleaned_data["recursive"]:
                article.set_permissions_recursive()
            if self.cleaned_data['recursive_owner']:
            if self.cleaned_data["recursive_owner"]:
                article.set_owner_recursive()
            if self.cleaned_data['recursive_group']:
            if self.cleaned_data["recursive_group"]:
                article.set_group_recursive()
            if self.cleaned_data[
                    'locked'] and not article.current_revision.locked:
            if self.cleaned_data["locked"] and not article.current_revision.locked:
                revision = models.ArticleRevision()
                revision.inherit_predecessor(self.article)
                revision.set_from_request(self.request)
                revision.automatic_log = _('Article locked for editing')
                revision.automatic_log = _("Article locked for editing")
                revision.locked = True
                self.article.add_revision(revision)
            elif not self.cleaned_data['locked'] and article.current_revision.locked:
            elif not self.cleaned_data["locked"] and article.current_revision.locked:
                revision = models.ArticleRevision()
                revision.inherit_predecessor(self.article)
                revision.set_from_request(self.request)
                revision.automatic_log = _('Article unlocked for editing')
                revision.automatic_log = _("Article unlocked for editing")
                revision.locked = False
                self.article.add_revision(revision)



@@ 554,16 589,17 @@ class PermissionsForm(PluginSettingsFormMixin, forms.ModelForm):
    class Meta:
        model = models.Article
        fields = (
            'locked',
            'owner_username',
            'recursive_owner',
            'group',
            'recursive_group',
            'group_read',
            'group_write',
            'other_read',
            'other_write',
            'recursive')
            "locked",
            "owner_username",
            "recursive_owner",
            "group",
            "recursive_group",
            "group_read",
            "group_write",
            "other_read",
            "other_write",
            "recursive",
        )
        widgets = {}




@@ 571,17 607,17 @@ class DirFilterForm(forms.Form):

    query = forms.CharField(
        widget=forms.TextInput(
            attrs={
                'placeholder': _('Filter...'),
                'class': 'search-query form-control'}),
        required=False)
            attrs={"placeholder": _("Filter..."), "class": "search-query form-control"}
        ),
        required=False,
    )


class SearchForm(forms.Form):

    q = forms.CharField(
        widget=forms.TextInput(
            attrs={
                'placeholder': _('Search...'),
                'class': 'search-query'}),
        required=False)
            attrs={"placeholder": _("Search..."), "class": "search-query"}
        ),
        required=False,
    )

M src/wiki/forms_account_handling.py => src/wiki/forms_account_handling.py +26 -15
@@ 26,14 26,20 @@ def check_user_field(user_model):


def check_email_field(user_model):
    return isinstance(_get_field(user_model, user_model.get_email_field_name()), EmailField)
    return isinstance(
        _get_field(user_model, user_model.get_email_field_name()), EmailField
    )


# django parses the ModelForm (and Meta classes) on class creation, which fails with custom models without expected fields.
# We need to check this here, because if this module can't load then system checks can't run.
CustomUser = User \
    if (settings.ACCOUNT_HANDLING and check_user_field(User) and check_email_field(User)) \
CustomUser = (
    User
    if (
        settings.ACCOUNT_HANDLING and check_user_field(User) and check_email_field(User)
    )
    else django.contrib.auth.models.User
)


class UserCreationForm(UserCreationForm):


@@ 44,16 50,16 @@ class UserCreationForm(UserCreationForm):

        # Add honeypots
        self.honeypot_fieldnames = "address", "phone"
        self.honeypot_class = ''.join(
            random.choice(string.ascii_uppercase + string.digits)
            for __ in range(10))
        self.honeypot_jsfunction = 'f' + ''.join(
            random.choice(string.ascii_uppercase + string.digits)
            for __ in range(10))
        self.honeypot_class = "".join(
            random.choice(string.ascii_uppercase + string.digits) for __ in range(10)
        )
        self.honeypot_jsfunction = "f" + "".join(
            random.choice(string.ascii_uppercase + string.digits) for __ in range(10)
        )

        for fieldname in self.honeypot_fieldnames:
            self.fields[fieldname] = forms.CharField(
                widget=forms.TextInput(attrs={'class': self.honeypot_class}),
                widget=forms.TextInput(attrs={"class": self.honeypot_class}),
                required=False,
            )



@@ 61,7 67,8 @@ class UserCreationForm(UserCreationForm):
        for fieldname in self.honeypot_fieldnames:
            if self.cleaned_data[fieldname]:
                raise forms.ValidationError(
                    "Thank you, non-human visitor. Please keep trying to fill in the form.")
                    "Thank you, non-human visitor. Please keep trying to fill in the form."
                )
        return self.cleaned_data

    class Meta:


@@ 70,12 77,16 @@ class UserCreationForm(UserCreationForm):


class UserUpdateForm(forms.ModelForm):
    password1 = forms.CharField(label="New password", widget=forms.PasswordInput(), required=False)
    password2 = forms.CharField(label="Confirm password", widget=forms.PasswordInput(), required=False)
    password1 = forms.CharField(
        label="New password", widget=forms.PasswordInput(), required=False
    )
    password2 = forms.CharField(
        label="Confirm password", widget=forms.PasswordInput(), required=False
    )

    def clean(self):
        password1 = self.cleaned_data.get('password1')
        password2 = self.cleaned_data.get('password2')
        password1 = self.cleaned_data.get("password1")
        password2 = self.cleaned_data.get("password2")

        if password1 and password1 != password2:
            raise forms.ValidationError(_("Passwords don't match"))

M src/wiki/managers.py => src/wiki/managers.py +27 -32
@@ 5,33 5,34 @@ from mptt.managers import TreeManager


class ArticleQuerySet(QuerySet):

    def can_read(self, user):
        """Filter objects so only the ones with a user's reading access
        are included"""
        if user.has_perm('wiki.moderator'):
        if user.has_perm("wiki.moderator"):
            return self
        if user.is_anonymous:
            q = self.filter(other_read=True)
        else:
            q = self.filter(Q(other_read=True) |
                            Q(owner=user) |
                            (Q(group__user=user) & Q(group_read=True))
                            ).annotate(Count('id'))
            q = self.filter(
                Q(other_read=True)
                | Q(owner=user)
                | (Q(group__user=user) & Q(group_read=True))
            ).annotate(Count("id"))
        return q

    def can_write(self, user):
        """Filter objects so only the ones with a user's writing access
        are included"""
        if user.has_perm('wiki.moderator'):
        if user.has_perm("wiki.moderator"):
            return self
        if user.is_anonymous:
            q = self.filter(other_write=True)
        else:
            q = self.filter(Q(other_write=True) |
                            Q(owner=user) |
                            (Q(group__user=user) & Q(group_write=True))
                            )
            q = self.filter(
                Q(other_write=True)
                | Q(owner=user)
                | (Q(group__user=user) & Q(group_write=True))
            )
        return q

    def active(self):


@@ 39,7 40,6 @@ class ArticleQuerySet(QuerySet):


class ArticleEmptyQuerySet(EmptyQuerySet):

    def can_read(self, user):
        return self



@@ 51,35 51,36 @@ class ArticleEmptyQuerySet(EmptyQuerySet):


class ArticleFkQuerySetMixin:

    def can_read(self, user):
        """Filter objects so only the ones with a user's reading access
        are included"""
        if user.has_perm('wiki.moderate'):
        if user.has_perm("wiki.moderate"):
            return self
        if user.is_anonymous:
            q = self.filter(article__other_read=True)
        else:
            # https://github.com/django-wiki/django-wiki/issues/67
            q = self.filter(
                Q(article__other_read=True) | Q(article__owner=user) |
                (Q(article__group__user=user) & Q(
                    article__group_read=True))).annotate(Count('id'))
                Q(article__other_read=True)
                | Q(article__owner=user)
                | (Q(article__group__user=user) & Q(article__group_read=True))
            ).annotate(Count("id"))
        return q

    def can_write(self, user):
        """Filter objects so only the ones with a user's writing access
        are included"""
        if user.has_perm('wiki.moderate'):
        if user.has_perm("wiki.moderate"):
            return self
        if user.is_anonymous:
            q = self.filter(article__other_write=True)
        else:
            # https://github.com/django-wiki/django-wiki/issues/67
            q = self.filter(
                Q(article__other_write=True) | Q(article__owner=user) |
                (Q(article__group__user=user) & Q(
                    article__group_write=True))).annotate(Count('id'))
                Q(article__other_write=True)
                | Q(article__owner=user)
                | (Q(article__group__user=user) & Q(article__group_write=True))
            ).annotate(Count("id"))
        return q

    def active(self):


@@ 87,7 88,6 @@ class ArticleFkQuerySetMixin:


class ArticleFkEmptyQuerySetMixin:

    def can_read(self, user):
        return self



@@ 107,7 107,6 @@ class ArticleFkEmptyQuerySet(ArticleFkEmptyQuerySetMixin, EmptyQuerySet):


class ArticleManager(models.Manager):

    def get_empty_query_set(self):
        return self.get_queryset().none()



@@ 125,7 124,6 @@ class ArticleManager(models.Manager):


class ArticleFkManager(models.Manager):

    def get_empty_query_set(self):
        return self.get_queryset().none()



@@ 143,7 141,6 @@ class ArticleFkManager(models.Manager):


class URLPathEmptyQuerySet(EmptyQuerySet, ArticleFkEmptyQuerySetMixin):

    def select_related_common(self):
        return self



@@ 152,27 149,25 @@ class URLPathEmptyQuerySet(EmptyQuerySet, ArticleFkEmptyQuerySetMixin):


class URLPathQuerySet(QuerySet, ArticleFkQuerySetMixin):

    def select_related_common(self):
        return self.select_related(
            "parent",
            "article__current_revision",
            "article__owner")
            "parent", "article__current_revision", "article__owner"
        )

    def default_order(self):
        """Returns elements by there article order"""
        return self.order_by('article__current_revision__title')
        return self.order_by("article__current_revision__title")


class URLPathManager(TreeManager):

    def get_empty_query_set(self):
        return self.get_queryset().none()

    def get_queryset(self):
        """Return a QuerySet with the same ordering as the TreeManager."""
        return URLPathQuerySet(self.model, using=self._db).order_by(
            self.tree_id_attr, self.left_attr)
            self.tree_id_attr, self.left_attr
        )

    def select_related_common(self):
        return self.get_queryset().common_select_related()

M src/wiki/migrations/0001_initial.py => src/wiki/migrations/0001_initial.py +361 -114
@@ 9,189 9,436 @@ from wiki.conf.settings import GROUP_MODEL
class Migration(migrations.Migration):

    dependencies = [
        ('sites', '0001_initial'),
        ("sites", "0001_initial"),
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
        ('contenttypes', '0001_initial'),
        ('auth', '0001_initial'),
        ("contenttypes", "0001_initial"),
        ("auth", "0001_initial"),
    ]

    operations = [
        migrations.CreateModel(
            name='Article',
            name="Article",
            fields=[
                ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
                ('created', models.DateTimeField(verbose_name='created', auto_now_add=True)),
                ('modified', models.DateTimeField(verbose_name='modified', auto_now=True, help_text='Article properties last modified')),
                ('group_read', models.BooleanField(default=True, verbose_name='group read access')),
                ('group_write', models.BooleanField(default=True, verbose_name='group write access')),
                ('other_read', models.BooleanField(default=True, verbose_name='others read access')),
                ('other_write', models.BooleanField(default=True, verbose_name='others write access')),
                (
                    "id",
                    models.AutoField(
                        serialize=False,
                        primary_key=True,
                        auto_created=True,
                        verbose_name="ID",
                    ),
                ),
                (
                    "created",
                    models.DateTimeField(verbose_name="created", auto_now_add=True),
                ),
                (
                    "modified",
                    models.DateTimeField(
                        verbose_name="modified",
                        auto_now=True,
                        help_text="Article properties last modified",
                    ),
                ),
                (
                    "group_read",
                    models.BooleanField(default=True, verbose_name="group read access"),
                ),
                (
                    "group_write",
                    models.BooleanField(
                        default=True, verbose_name="group write access"
                    ),
                ),
                (
                    "other_read",
                    models.BooleanField(
                        default=True, verbose_name="others read access"
                    ),
                ),
                (
                    "other_write",
                    models.BooleanField(
                        default=True, verbose_name="others write access"
                    ),
                ),
            ],
            options={
                'permissions': (('moderate', 'Can edit all articles and lock/unlock/restore'), ('assign', 'Can change ownership of any article'), ('grant', 'Can assign permissions to other users')),
                "permissions": (
                    ("moderate", "Can edit all articles and lock/unlock/restore"),
                    ("assign", "Can change ownership of any article"),
                    ("grant", "Can assign permissions to other users"),
                ),
            },
            bases=(models.Model,),
        ),
        migrations.CreateModel(
            name='ArticleForObject',
            name="ArticleForObject",
            fields=[
                ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
                ('object_id', models.PositiveIntegerField(verbose_name='object ID')),
                ('is_mptt', models.BooleanField(default=False, editable=False)),
                ('article', models.ForeignKey(to='wiki.Article', on_delete=models.CASCADE)),
                ('content_type', models.ForeignKey(related_name='content_type_set_for_articleforobject', verbose_name='content type', to='contenttypes.ContentType', on_delete=models.CASCADE)),
                (
                    "id",
                    models.AutoField(
                        serialize=False,
                        primary_key=True,
                        auto_created=True,
                        verbose_name="ID",
                    ),
                ),
                ("object_id", models.PositiveIntegerField(verbose_name="object ID")),
                ("is_mptt", models.BooleanField(default=False, editable=False)),
                (
                    "article",
                    models.ForeignKey(to="wiki.Article", on_delete=models.CASCADE),
                ),
                (
                    "content_type",
                    models.ForeignKey(
                        related_name="content_type_set_for_articleforobject",
                        verbose_name="content type",
                        to="contenttypes.ContentType",
                        on_delete=models.CASCADE,
                    ),
                ),
            ],
            options={
                'verbose_name_plural': 'Articles for object',
                'verbose_name': 'Article for object',
                "verbose_name_plural": "Articles for object",
                "verbose_name": "Article for object",
            },
            bases=(models.Model,),
        ),
        migrations.CreateModel(
            name='ArticlePlugin',
            name="ArticlePlugin",
            fields=[
                ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
                ('deleted', models.BooleanField(default=False)),
                ('created', models.DateTimeField(auto_now_add=True)),
                (
                    "id",
                    models.AutoField(
                        serialize=False,
                        primary_key=True,
                        auto_created=True,
                        verbose_name="ID",
                    ),
                ),
                ("deleted", models.BooleanField(default=False)),
                ("created", models.DateTimeField(auto_now_add=True)),
            ],
            options={
            },
            options={},
            bases=(models.Model,),
        ),
        migrations.CreateModel(
            name='ArticleRevision',
            name="ArticleRevision",
            fields=[
                ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
                ('revision_number', models.IntegerField(verbose_name='revision number', editable=False)),
                ('user_message', models.TextField(blank=True)),
                ('automatic_log', models.TextField(blank=True, editable=False)),
                ('ip_address', IPAddressField(null=True, verbose_name='IP address', blank=True, editable=False)),
                ('modified', models.DateTimeField(auto_now=True)),
                ('created', models.DateTimeField(auto_now_add=True)),
                ('deleted', models.BooleanField(default=False, verbose_name='deleted')),
                ('locked', models.BooleanField(default=False, verbose_name='locked')),
                ('content', models.TextField(blank=True, verbose_name='article contents')),
                ('title', models.CharField(max_length=512, verbose_name='article title', help_text='Each revision contains a title field that must be filled out, even if the title has not changed')),
                ('article', models.ForeignKey(to='wiki.Article', verbose_name='article', on_delete=models.CASCADE)),
                ('previous_revision', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, blank=True, to='wiki.ArticleRevision')),
                ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, verbose_name='user')),
                (
                    "id",
                    models.AutoField(
                        serialize=False,
                        primary_key=True,
                        auto_created=True,
                        verbose_name="ID",
                    ),
                ),
                (
                    "revision_number",
                    models.IntegerField(verbose_name="revision number", editable=False),
                ),
                ("user_message", models.TextField(blank=True)),
                ("automatic_log", models.TextField(blank=True, editable=False)),
                (
                    "ip_address",
                    IPAddressField(
                        null=True, verbose_name="IP address", blank=True, editable=False
                    ),
                ),
                ("modified", models.DateTimeField(auto_now=True)),
                ("created", models.DateTimeField(auto_now_add=True)),
                ("deleted", models.BooleanField(default=False, verbose_name="deleted")),
                ("locked", models.BooleanField(default=False, verbose_name="locked")),
                (
                    "content",
                    models.TextField(blank=True, verbose_name="article contents"),
                ),
                (
                    "title",
                    models.CharField(
                        max_length=512,
                        verbose_name="article title",
                        help_text="Each revision contains a title field that must be filled out, even if the title has not changed",
                    ),
                ),
                (
                    "article",
                    models.ForeignKey(
                        to="wiki.Article",
                        verbose_name="article",
                        on_delete=models.CASCADE,
                    ),
                ),
                (
                    "previous_revision",
                    models.ForeignKey(
                        null=True,
                        on_delete=django.db.models.deletion.SET_NULL,
                        blank=True,
                        to="wiki.ArticleRevision",
                    ),
                ),
                (
                    "user",
                    models.ForeignKey(
                        null=True,
                        on_delete=django.db.models.deletion.SET_NULL,
                        blank=True,
                        to=settings.AUTH_USER_MODEL,
                        verbose_name="user",
                    ),
                ),
            ],
            options={
                'get_latest_by': 'revision_number',
                'ordering': ('created',),
            },
            options={"get_latest_by": "revision_number", "ordering": ("created",),},
            bases=(models.Model,),
        ),
        migrations.CreateModel(
            name='ReusablePlugin',
            name="ReusablePlugin",
            fields=[
                ('articleplugin_ptr', models.OneToOneField(primary_key=True, parent_link=True, to='wiki.ArticlePlugin', serialize=False, auto_created=True, on_delete=models.CASCADE)),
                ('articles', models.ManyToManyField(related_name='shared_plugins_set', to='wiki.Article')),
                (
                    "articleplugin_ptr",
                    models.OneToOneField(
                        primary_key=True,
                        parent_link=True,
                        to="wiki.ArticlePlugin",
                        serialize=False,
                        auto_created=True,
                        on_delete=models.CASCADE,
                    ),
                ),
                (
                    "articles",
                    models.ManyToManyField(
                        related_name="shared_plugins_set", to="wiki.Article"
                    ),
                ),
            ],
            options={
            },
            bases=('wiki.articleplugin',),
            options={},
            bases=("wiki.articleplugin",),
        ),
        migrations.CreateModel(
            name='RevisionPlugin',
            name="RevisionPlugin",
            fields=[
                ('articleplugin_ptr', models.OneToOneField(primary_key=True, parent_link=True, to='wiki.ArticlePlugin', serialize=False, auto_created=True, on_delete=models.CASCADE)),
                (
                    "articleplugin_ptr",
                    models.OneToOneField(
                        primary_key=True,
                        parent_link=True,
                        to="wiki.ArticlePlugin",
                        serialize=False,
                        auto_created=True,
                        on_delete=models.CASCADE,
                    ),
                ),
            ],
            options={
            },
            bases=('wiki.articleplugin',),
            options={},
            bases=("wiki.articleplugin",),
        ),
        migrations.CreateModel(
            name='RevisionPluginRevision',
            name="RevisionPluginRevision",
            fields=[
                ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
                ('revision_number', models.IntegerField(verbose_name='revision number', editable=False)),
                ('user_message', models.TextField(blank=True)),
                ('automatic_log', models.TextField(blank=True, editable=False)),
                ('ip_address', IPAddressField(null=True, verbose_name='IP address', blank=True, editable=False)),
                ('modified', models.DateTimeField(auto_now=True)),
                ('created', models.DateTimeField(auto_now_add=True)),
                ('deleted', models.BooleanField(default=False, verbose_name='deleted')),
                ('locked', models.BooleanField(default=False, verbose_name='locked')),
                ('plugin', models.ForeignKey(related_name='revision_set', to='wiki.RevisionPlugin', on_delete=models.CASCADE)),
                ('previous_revision', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, blank=True, to='wiki.RevisionPluginRevision')),
                ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, verbose_name='user')),
                (
                    "id",
                    models.AutoField(
                        serialize=False,
                        primary_key=True,
                        auto_created=True,
                        verbose_name="ID",
                    ),
                ),
                (
                    "revision_number",
                    models.IntegerField(verbose_name="revision number", editable=False),
                ),
                ("user_message", models.TextField(blank=True)),
                ("automatic_log", models.TextField(blank=True, editable=False)),
                (
                    "ip_address",
                    IPAddressField(
                        null=True, verbose_name="IP address", blank=True, editable=False
                    ),
                ),
                ("modified", models.DateTimeField(auto_now=True)),
                ("created", models.DateTimeField(auto_now_add=True)),
                ("deleted", models.BooleanField(default=False, verbose_name="deleted")),
                ("locked", models.BooleanField(default=False, verbose_name="locked")),
                (
                    "plugin",
                    models.ForeignKey(
                        related_name="revision_set",
                        to="wiki.RevisionPlugin",
                        on_delete=models.CASCADE,
                    ),
                ),
                (
                    "previous_revision",
                    models.ForeignKey(
                        null=True,
                        on_delete=django.db.models.deletion.SET_NULL,
                        blank=True,
                        to="wiki.RevisionPluginRevision",
                    ),
                ),
                (
                    "user",
                    models.ForeignKey(
                        null=True,
                        on_delete=django.db.models.deletion.SET_NULL,
                        blank=True,
                        to=settings.AUTH_USER_MODEL,
                        verbose_name="user",
                    ),
                ),
            ],
            options={
                'get_latest_by': 'revision_number',
                'ordering': ('-created',),
            },
            options={"get_latest_by": "revision_number", "ordering": ("-created",),},
            bases=(models.Model,),
        ),
        migrations.CreateModel(
            name='SimplePlugin',
            name="SimplePlugin",
            fields=[
                ('articleplugin_ptr', models.OneToOneField(primary_key=True, parent_link=True, to='wiki.ArticlePlugin', serialize=False, auto_created=True, on_delete=models.CASCADE)),
                ('article_revision', models.ForeignKey(to='wiki.ArticleRevision', on_delete=models.CASCADE)),
                (
                    "articleplugin_ptr",
                    models.OneToOneField(
                        primary_key=True,
                        parent_link=True,
                        to="wiki.ArticlePlugin",
                        serialize=False,
                        auto_created=True,
                        on_delete=models.CASCADE,
                    ),
                ),
                (
                    "article_revision",
                    models.ForeignKey(
                        to="wiki.ArticleRevision", on_delete=models.CASCADE
                    ),
                ),
            ],
            options={
            },
            bases=('wiki.articleplugin',),
            options={},
            bases=("wiki.articleplugin",),
        ),
        migrations.CreateModel(
            name='URLPath',
            name="URLPath",
            fields=[
                ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
                ('slug', models.SlugField(null=True, blank=True, verbose_name='slug')),
                ('lft', models.PositiveIntegerField(db_index=True, editable=False)),
                ('rght', models.PositiveIntegerField(db_index=True, editable=False)),
                ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
                ('level', models.PositiveIntegerField(db_index=True, editable=False)),
                ('article', models.ForeignKey(help_text='This field is automatically updated, but you need to populate it when creating a new URL path.', on_delete=django.db.models.deletion.CASCADE, to='wiki.Article', verbose_name='article')),
                ('parent', mptt.fields.TreeForeignKey(blank=True, help_text='Position of URL path in the tree.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='wiki.URLPath')),
                ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')),
                (
                    "id",
                    models.AutoField(
                        serialize=False,
                        primary_key=True,
                        auto_created=True,
                        verbose_name="ID",
                    ),
                ),
                ("slug", models.SlugField(null=True, blank=True, verbose_name="slug")),
                ("lft", models.PositiveIntegerField(db_index=True, editable=False)),
                ("rght", models.PositiveIntegerField(db_index=True, editable=False)),
                ("tree_id", models.PositiveIntegerField(db_index=True, editable=False)),
                ("level", models.PositiveIntegerField(db_index=True, editable=False)),
                (
                    "article",
                    models.ForeignKey(
                        help_text="This field is automatically updated, but you need to populate it when creating a new URL path.",
                        on_delete=django.db.models.deletion.CASCADE,
                        to="wiki.Article",
                        verbose_name="article",
                    ),
                ),
                (
                    "parent",
                    mptt.fields.TreeForeignKey(
                        blank=True,
                        help_text="Position of URL path in the tree.",
                        null=True,
                        on_delete=django.db.models.deletion.CASCADE,
                        related_name="children",
                        to="wiki.URLPath",
                    ),
                ),
                (
                    "site",
                    models.ForeignKey(
                        on_delete=django.db.models.deletion.CASCADE, to="sites.Site"
                    ),
                ),
            ],
            options={
                'verbose_name_plural': 'URL paths',
                'verbose_name': 'URL path',
            },
            options={"verbose_name_plural": "URL paths", "verbose_name": "URL path",},
            bases=(models.Model,),
        ),
        migrations.AlterUniqueTogether(
            name='urlpath',
            unique_together=set([('site', 'parent', 'slug')]),
            name="urlpath", unique_together=set([("site", "parent", "slug")]),
        ),
        migrations.AddField(
            model_name='revisionplugin',
            name='current_revision',
            field=models.OneToOneField(related_name='plugin_set', null=True, help_text='The revision being displayed for this plugin. If you need to do a roll-back, simply change the value of this field.', blank=True, to='wiki.RevisionPluginRevision', verbose_name='current revision', on_delete=models.CASCADE),
            model_name="revisionplugin",
            name="current_revision",
            field=models.OneToOneField(
                related_name="plugin_set",
                null=True,
                help_text="The revision being displayed for this plugin. If you need to do a roll-back, simply change the value of this field.",
                blank=True,
                to="wiki.RevisionPluginRevision",
                verbose_name="current revision",
                on_delete=models.CASCADE,
            ),
            preserve_default=True,
        ),
        migrations.AlterUniqueTogether(
            name='articlerevision',
            unique_together=set([('article', 'revision_number')]),
            name="articlerevision",
            unique_together=set([("article", "revision_number")]),
        ),
        migrations.AddField(
            model_name='articleplugin',
            name='article',
            field=models.ForeignKey(to='wiki.Article', verbose_name='article', on_delete=models.CASCADE),
            model_name="articleplugin",
            name="article",
            field=models.ForeignKey(
                to="wiki.Article", verbose_name="article", on_delete=models.CASCADE
            ),
            preserve_default=True,
        ),
        migrations.AlterUniqueTogether(
            name='articleforobject',
            unique_together=set([('content_type', 'object_id')]),
            name="articleforobject",
            unique_together=set([("content_type", "object_id")]),
        ),
        migrations.AddField(
            model_name='article',
            name='current_revision',
            field=models.OneToOneField(related_name='current_set', null=True, help_text='The revision being displayed for this article. If you need to do a roll-back, simply change the value of this field.', blank=True, to='wiki.ArticleRevision', verbose_name='current revision', on_delete=models.CASCADE),
            model_name="article",
            name="current_revision",
            field=models.OneToOneField(
                related_name="current_set",
                null=True,
                help_text="The revision being displayed for this article. If you need to do a roll-back, simply change the value of this field.",
                blank=True,
                to="wiki.ArticleRevision",
                verbose_name="current revision",
                on_delete=models.CASCADE,
            ),
            preserve_default=True,
        ),
        migrations.AddField(
            model_name='article',
            name='group',
            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, help_text='Like in a UNIX file system, permissions can be given to a user according to group membership. Groups are handled through the Django auth system.', blank=True, to=GROUP_MODEL, verbose_name='group'),
            model_name="article",
            name="group",
            field=models.ForeignKey(
                null=True,
                on_delete=django.db.models.deletion.SET_NULL,
                help_text="Like in a UNIX file system, permissions can be given to a user according to group membership. Groups are handled through the Django auth system.",
                blank=True,
                to=GROUP_MODEL,
                verbose_name="group",
            ),
            preserve_default=True,
        ),
        migrations.AddField(
            model_name='article',
            name='owner',
            field=models.ForeignKey(related_name='owned_articles', null=True, on_delete=django.db.models.deletion.SET_NULL, help_text='The owner of the article, usually the creator. The owner always has both read and write access.', blank=True, to=settings.AUTH_USER_MODEL, verbose_name='owner'),
            model_name="article",
            name="owner",
            field=models.ForeignKey(
                related_name="owned_articles",
                null=True,
                on_delete=django.db.models.deletion.SET_NULL,
                help_text="The owner of the article, usually the creator. The owner always has both read and write access.",
                blank=True,
                to=settings.AUTH_USER_MODEL,
                verbose_name="owner",
            ),
            preserve_default=True,
        ),
    ]

M src/wiki/migrations/0002_urlpath_moved_to.py => src/wiki/migrations/0002_urlpath_moved_to.py +12 -4
@@ 7,13 7,21 @@ from django.db import migrations
class Migration(migrations.Migration):

    dependencies = [
        ('wiki', '0001_initial'),
        ("wiki", "0001_initial"),
    ]

    operations = [
        migrations.AddField(
            model_name='urlpath',
            name='moved_to',
            field=mptt.fields.TreeForeignKey(blank=True, help_text='Article path was moved to this location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moved_from', to='wiki.URLPath', verbose_name='Moved to'),
            model_name="urlpath",
            name="moved_to",
            field=mptt.fields.TreeForeignKey(
                blank=True,
                help_text="Article path was moved to this location",
                null=True,
                on_delete=django.db.models.deletion.SET_NULL,
                related_name="moved_from",
                to="wiki.URLPath",
                verbose_name="Moved to",
            ),
        ),
    ]

M src/wiki/models/__init__.py => src/wiki/models/__init__.py +7 -7
@@ 19,17 19,17 @@ def reverse(*args, **kwargs):
    return the result of calling reverse._transform_url(reversed_url)
    for every url in the wiki namespace.
    """
    if isinstance(args[0], str) and args[0].startswith('wiki:'):
        url_kwargs = kwargs.get('kwargs', {})
        path = url_kwargs.get('path', False)
    if isinstance(args[0], str) and args[0].startswith("wiki:"):
        url_kwargs = kwargs.get("kwargs", {})
        path = url_kwargs.get("path", False)
        # If a path is supplied then discard the article_id
        if path is not False:
            url_kwargs.pop('article_id', None)
            url_kwargs['path'] = path
            kwargs['kwargs'] = url_kwargs
            url_kwargs.pop("article_id", None)
            url_kwargs["path"] = path
            kwargs["kwargs"] = url_kwargs

        url = original_django_reverse(*args, **kwargs)
        if hasattr(reverse, '_transform_url'):
        if hasattr(reverse, "_transform_url"):
            url = reverse._transform_url(url)
    else:
        url = original_django_reverse(*args, **kwargs)

M src/wiki/models/article.py => src/wiki/models/article.py +105 -92
@@ 17,8 17,10 @@ from wiki.core.markdown import article_markdown
from wiki.decorators import disable_signal_for_loaddata

__all__ = [
    'Article', 'ArticleForObject', 'ArticleRevision',
    'BaseRevisionMixin',
    "Article",
    "ArticleForObject",
    "ArticleRevision",
    "BaseRevisionMixin",
]




@@ 27,47 29,55 @@ class Article(models.Model):
    objects = managers.ArticleManager()

    current_revision = models.OneToOneField(
        'ArticleRevision', verbose_name=_('current revision'),
        blank=True, null=True, related_name='current_set',
        "ArticleRevision",
        verbose_name=_("current revision"),
        blank=True,
        null=True,
        related_name="current_set",
        on_delete=models.CASCADE,
        help_text=_(
            'The revision being displayed for this article. If you need to do a roll-back, simply change the value of this field.'),)

    created = models.DateTimeField(
        auto_now_add=True,
        verbose_name=_('created'),
            "The revision being displayed for this article. If you need to do a roll-back, simply change the value of this field."
        ),
    )

    created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"),)
    modified = models.DateTimeField(
        auto_now=True,
        verbose_name=_('modified'),
        help_text=_('Article properties last modified'))
        verbose_name=_("modified"),
        help_text=_("Article properties last modified"),
    )

    owner = models.ForeignKey(
        django_settings.AUTH_USER_MODEL, verbose_name=_('owner'),
        blank=True, null=True, related_name='owned_articles',
        django_settings.AUTH_USER_MODEL,
        verbose_name=_("owner"),
        blank=True,
        null=True,
        related_name="owned_articles",
        help_text=_(
            'The owner of the article, usually the creator. The owner always has both read and write access.'),
        on_delete=models.SET_NULL)
            "The owner of the article, usually the creator. The owner always has both read and write access."
        ),
        on_delete=models.SET_NULL,
    )

    group = models.ForeignKey(
        settings.GROUP_MODEL, verbose_name=_('group'),
        blank=True, null=True,
        settings.GROUP_MODEL,
        verbose_name=_("group"),
        blank=True,
        null=True,
        help_text=_(
            'Like in a UNIX file system, permissions can be given to a user according to group membership. Groups are handled through the Django auth system.'),
        on_delete=models.SET_NULL)
            "Like in a UNIX file system, permissions can be given to a user according to group membership. Groups are handled through the Django auth system."
        ),
        on_delete=models.SET_NULL,
    )

    group_read = models.BooleanField(
        default=True,
        verbose_name=_('group read access'))
    group_read = models.BooleanField(default=True, verbose_name=_("group read access"))
    group_write = models.BooleanField(
        default=True,
        verbose_name=_('group write access'))
    other_read = models.BooleanField(
        default=True,
        verbose_name=_('others read access'))
        default=True, verbose_name=_("group write access")
    )
    other_read = models.BooleanField(default=True, verbose_name=_("others read access"))
    other_write = models.BooleanField(
        default=True,
        verbose_name=_('others write access'))
        default=True, verbose_name=_("others write access")
    )

    # PERMISSIONS
    def can_read(self, user):


@@ 100,11 110,14 @@ class Article(models.Model):
        cnt = 0
        for obj in self.articleforobject_set.filter(is_mptt=True):
            if user_can_read:
                objects = obj.content_object.get_children().filter(
                    **kwargs).can_read(user_can_read)
                objects = (
                    obj.content_object.get_children()
                    .filter(**kwargs)
                    .can_read(user_can_read)
                )
            else:
                objects = obj.content_object.get_children().filter(**kwargs)
            for child in objects.order_by('articles__article__current_revision__title'):
            for child in objects.order_by("articles__article__current_revision__title"):
                cnt += 1
                if max_num and cnt > max_num:
                    return


@@ 140,9 153,10 @@ class Article(models.Model):
        revision.
        """
        assert self.id or save, (
            'Article.add_revision: Sorry, you cannot add a'
            'revision to an article that has not been saved '
            'without using save=True')
            "Article.add_revision: Sorry, you cannot add a"
            "revision to an article that has not been saved "
            "without using save=True"
        )
        if not self.id:
            self.save()
        revisions = self.articlerevision_set.all()


@@ 170,14 184,13 @@ class Article(models.Model):
    @classmethod
    def get_for_object(cls, obj):
        return ArticleForObject.objects.get(
            object_id=obj.id,
            content_type=ContentType.objects.get_for_model(obj),
            object_id=obj.id, content_type=ContentType.objects.get_for_model(obj),
        ).article

    def __str__(self):
        if self.current_revision:
            return self.current_revision.title
        obj_name = _('Article without content (%(id)d)') % {'id': self.id}
        obj_name = _("Article without content (%(id)d)") % {"id": self.id}
        return str(obj_name)

    class Meta:


@@ 194,10 207,10 @@ class Article(models.Model):
            content = preview_content
        else:
            content = self.current_revision.content
        return mark_safe(article_markdown(
            content, self,
            preview=preview_content is not None,
            user=user)
        return mark_safe(
            article_markdown(
                content, self, preview=preview_content is not None, user=user
            )
        )

    def get_cache_key(self):


@@ 205,14 218,14 @@ class Article(models.Model):
        lang = translation.get_language()

        return "wiki:article:{id}:{lang}".format(
            id=self.current_revision.id if self.current_revision else self.id,
            lang=lang)
            id=self.current_revision.id if self.current_revision else self.id, lang=lang
        )

    def get_cache_content_key(self, user=None):
        """Returns per-article-user cache key."""
        return "{key}:{user}".format(
            key=self.get_cache_key(),
            user=user.get_username() if user else "")
            key=self.get_cache_key(), user=user.get_username() if user else ""
        )

    def get_cached_content(self, user=None):
        """Returns cached version of rendered article.


@@ 247,25 260,26 @@ class Article(models.Model):
    def get_url_kwargs(self):
        urlpaths = self.urlpath_set.all()
        if urlpaths.exists():
            return {'path': urlpaths[0].path}
        return {'article_id': self.id}
            return {"path": urlpaths[0].path}
        return {"article_id": self.id}

    def get_absolute_url(self):
        return reverse('wiki:get', kwargs=self.get_url_kwargs())
        return reverse("wiki:get", kwargs=self.get_url_kwargs())


class ArticleForObject(models.Model):

    objects = managers.ArticleFkManager()

    article = models.ForeignKey('Article', on_delete=models.CASCADE)
    article = models.ForeignKey("Article", on_delete=models.CASCADE)
    # Same as django.contrib.comments
    content_type = models.ForeignKey(
        ContentType,
        on_delete=models.CASCADE,
        verbose_name=_('content type'),
        related_name="content_type_set_for_%(class)s")
    object_id = models.PositiveIntegerField(_('object ID'))
        verbose_name=_("content type"),
        related_name="content_type_set_for_%(class)s",
    )
    object_id = models.PositiveIntegerField(_("object ID"))
    content_object = GenericForeignKey("content_type", "object_id")

    is_mptt = models.BooleanField(default=False, editable=False)


@@ 274,10 288,10 @@ class ArticleForObject(models.Model):
        return str(self.article)

    class Meta:
        verbose_name = _('Article for object')
        verbose_name_plural = _('Articles for object')
        verbose_name = _("Article for object")
        verbose_name_plural = _("Articles for object")
        # Do not allow several objects
        unique_together = ('content_type', 'object_id')
        unique_together = ("content_type", "object_id")


class BaseRevisionMixin(models.Model):


@@ 286,47 300,41 @@ class BaseRevisionMixin(models.Model):
    core model methods but respect the inheritor's freedom to do so itself."""

    revision_number = models.IntegerField(
        editable=False,
        verbose_name=_('revision number'))
        editable=False, verbose_name=_("revision number")
    )

    user_message = models.TextField(blank=True,)
    automatic_log = models.TextField(blank=True, editable=False,)

    ip_address = IPAddressField(
        _('IP address'),
    ip_address = IPAddressField(_("IP address"), blank=True, null=True, editable=False)
    user = models.ForeignKey(
        django_settings.AUTH_USER_MODEL,
        verbose_name=_("user"),
        blank=True,
        null=True,
        editable=False)
    user = models.ForeignKey(django_settings.AUTH_USER_MODEL, verbose_name=_('user'),
                             blank=True, null=True,
                             on_delete=models.SET_NULL)
        on_delete=models.SET_NULL,
    )

    modified = models.DateTimeField(auto_now=True)
    created = models.DateTimeField(auto_now_add=True)

    previous_revision = models.ForeignKey(
        'self', blank=True, null=True, on_delete=models.SET_NULL
        "self", blank=True, null=True, on_delete=models.SET_NULL
    )

    # NOTE! The semantics of these fields are not related to the revision itself
    # but the actual related object. If the latest revision says "deleted=True" then
    # the related object should be regarded as deleted.
    deleted = models.BooleanField(
        verbose_name=_('deleted'),
        default=False,
    )
    locked = models.BooleanField(
        verbose_name=_('locked'),
        default=False,
    )
    deleted = models.BooleanField(verbose_name=_("deleted"), default=False,)
    locked = models.BooleanField(verbose_name=_("locked"), default=False,)

    def set_from_request(self, request):
        if request.user.is_authenticated:
            self.user = request.user
            if settings.LOG_IPS_USERS:
                self.ip_address = request.META.get('REMOTE_ADDR', None)
                self.ip_address = request.META.get("REMOTE_ADDR", None)
        elif settings.LOG_IPS_ANONYMOUS:
            self.ip_address = request.META.get('REMOTE_ADDR', None)
            self.ip_address = request.META.get("REMOTE_ADDR", None)

    def inherit_predecessor(self, predecessor):
        """


@@ 353,19 361,24 @@ class ArticleRevision(BaseRevisionMixin, models.Model):

    objects = managers.ArticleFkManager()

    article = models.ForeignKey('Article', on_delete=models.CASCADE,
                                verbose_name=_('article'))
    article = models.ForeignKey(
        "Article", on_delete=models.CASCADE, verbose_name=_("article")
    )

    # This is where the content goes, with whatever markup language is used
    content = models.TextField(blank=True, verbose_name=_('article contents'))
    content = models.TextField(blank=True, verbose_name=_("article contents"))

    # This title is automatically set from either the article's title or
    # the last used revision...
    title = models.CharField(
        max_length=512, verbose_name=_('article title'),
        null=False, blank=False,
        max_length=512,
        verbose_name=_("article title"),
        null=False,
        blank=False,
        help_text=_(
            'Each revision contains a title field that must be filled out, even if the title has not changed'))
            "Each revision contains a title field that must be filled out, even if the title has not changed"
        ),
    )

    # TODO:
    # Allow a revision to redirect to another *article*. This


@@ 382,7 395,7 @@ class ArticleRevision(BaseRevisionMixin, models.Model):
        # Enforce DOS line endings \r\n. It is the standard for web browsers,
        # but when revisions are created programatically, they might
        # have UNIX line endings \n instead.
        self.content = self.content.replace('\r', '').replace('\n', '\r\n')
        self.content = self.content.replace("\r", "").replace("\n", "\r\n")

    def inherit_predecessor(self, article):
        """


@@ 397,9 410,9 @@ class ArticleRevision(BaseRevisionMixin, models.Model):
        self.locked = predecessor.locked

    class Meta:
        get_latest_by = 'revision_number'
        ordering = ('created',)
        unique_together = ('article', 'revision_number')
        get_latest_by = "revision_number"
        ordering = ("created",)
        unique_together = ("article", "revision_number")


######################################################


@@ 426,13 439,13 @@ def on_article_delete_clear_cache(instance, **kwargs):

@disable_signal_for_loaddata
def on_article_revision_pre_save(**kwargs):
    instance = kwargs['instance']
    if kwargs.get('created', False):
    instance = kwargs["instance"]
    if kwargs.get("created", False):
        revision_changed = (
            not instance.previous_revision and
            instance.article and
            instance.article.current_revision and
            instance.article.current_revision != instance
            not instance.previous_revision
            and instance.article
            and instance.article.current_revision
            and instance.article.current_revision != instance
        )
        if revision_changed:
            instance.previous_revision = instance.article.current_revision


@@ 448,7 461,7 @@ def on_article_revision_pre_save(**kwargs):
@disable_signal_for_loaddata
def on_article_revision_post_save(**kwargs):

    instance = kwargs['instance']
    instance = kwargs["instance"]
    if not instance.article.current_revision:
        # If I'm saved from Django admin, then article.current_revision is
        # me!

M src/wiki/models/pluginbase.py => src/wiki/models/pluginbase.py +53 -47
@@ 26,10 26,12 @@ from wiki.decorators import disable_signal_for_loaddata
from .article import ArticleRevision, BaseRevisionMixin

__all__ = [
    'ArticlePlugin',
    'SimplePlugin', 'SimplePluginCreateError',
    'ReusablePlugin',
    'RevisionPlugin', 'RevisionPluginRevision',
    "ArticlePlugin",
    "SimplePlugin",
    "SimplePluginCreateError",
    "ReusablePlugin",
    "RevisionPlugin",
    "RevisionPluginRevision",
]




@@ 40,8 42,9 @@ class ArticlePlugin(models.Model):
    clean. Furthermore, it's possible to list all plugins and maintain generic
    properties in the future..."""

    article = models.ForeignKey('wiki.Article', on_delete=models.CASCADE,
                                verbose_name=_("article"))
    article = models.ForeignKey(
        "wiki.Article", on_delete=models.CASCADE, verbose_name=_("article")
    )

    deleted = models.BooleanField(default=False)



@@ 79,18 82,16 @@ class ReusablePlugin(ArticlePlugin):
    You might have to override the permission methods (can_read, can_write etc.)
    if you have certain needs for logic in your reusable plugin.
    """

    # The article on which the plugin was originally created.
    # Used to apply permissions.
    ArticlePlugin.article.on_delete = models.SET_NULL
    ArticlePlugin.article.verbose_name = _('original article')
    ArticlePlugin.article.help_text = _(
        'Permissions are inherited from this article')
    ArticlePlugin.article.verbose_name = _("original article")
    ArticlePlugin.article.help_text = _("Permissions are inherited from this article")
    ArticlePlugin.article.null = True
    ArticlePlugin.article.blank = True

    articles = models.ManyToManyField(
        'wiki.Article',
        related_name='shared_plugins_set')
    articles = models.ManyToManyField("wiki.Article", related_name="shared_plugins_set")

    # Since the article relation may be None, we have to check for this
    # before handling permissions....


@@ 130,17 131,17 @@ class SimplePlugin(ArticlePlugin):
    YourPlugin(article=article_instance, ...) or
    YourPlugin.objects.create(article=article_instance, ...)
    """

    # The article revision that this plugin is attached to
    article_revision = models.ForeignKey(
        'wiki.ArticleRevision',
        on_delete=models.CASCADE)
        "wiki.ArticleRevision", on_delete=models.CASCADE
    )

    def __init__(self, *args, **kwargs):
        article = kwargs.pop('article', None)
        article = kwargs.pop("article", None)
        super().__init__(*args, **kwargs)
        if not self.pk and not article:
            raise SimplePluginCreateError(
                "Keyword argument 'article' expected.")
            raise SimplePluginCreateError("Keyword argument 'article' expected.")
        elif self.pk:
            self.article = self.article_revision.article
        else:


@@ 159,17 160,19 @@ class RevisionPlugin(ArticlePlugin):
    This kind of plugin is not attached to article plugins so rolling articles
    back and forth does not affect it.
    """

    # The current revision of this plugin, if any!
    current_revision = models.OneToOneField(
        'RevisionPluginRevision',
        verbose_name=_('current revision'),
        "RevisionPluginRevision",
        verbose_name=_("current revision"),
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        related_name='plugin_set',
        related_name="plugin_set",
        help_text=_(
            'The revision being displayed for this plugin. '
            'If you need to do a roll-back, simply change the value of this field.'),
            "The revision being displayed for this plugin. "
            "If you need to do a roll-back, simply change the value of this field."
        ),
    )

    def add_revision(self, new_revision, save=True):


@@ 178,15 181,15 @@ class RevisionPlugin(ArticlePlugin):
        revision.
        """
        assert self.id or save, (
            'RevisionPluginRevision.add_revision: Sorry, you cannot add a'
            'revision to a plugin that has not been saved '
            'without using save=True')
            "RevisionPluginRevision.add_revision: Sorry, you cannot add a"
            "revision to a plugin that has not been saved "
            "without using save=True"
        )
        if not self.id:
            self.save()
        revisions = self.revision_set.all()
        try:
            new_revision.revision_number = revisions.latest(
            ).revision_number + 1
            new_revision.revision_number = revisions.latest().revision_number + 1
        except RevisionPluginRevision.DoesNotExist:
            new_revision.revision_number = 0
        new_revision.plugin = self


@@ 207,13 210,15 @@ class RevisionPluginRevision(BaseRevisionMixin, models.Model):
    (this class is very much copied from wiki.models.article.ArticleRevision
    """

    plugin = models.ForeignKey(RevisionPlugin, on_delete=models.CASCADE, related_name='revision_set')
    plugin = models.ForeignKey(
        RevisionPlugin, on_delete=models.CASCADE, related_name="revision_set"
    )

    class Meta:
        # Override this setting with app_label = '' in your extended model
        # if it lives outside the wiki app.
        get_latest_by = 'revision_number'
        ordering = ('-created',)
        get_latest_by = "revision_number"
        ordering = ("-created",)


######################################################


@@ 230,22 235,23 @@ class RevisionPluginRevision(BaseRevisionMixin, models.Model):
def update_simple_plugins(**kwargs):
    """Every time a new article revision is created, we update all active
    plugins to match this article revision"""
    instance = kwargs['instance']
    if kwargs.get('created', False):
    instance = kwargs["instance"]
    if kwargs.get("created", False):
        p_revisions = SimplePlugin.objects.filter(
            article=instance.article,
            deleted=False)
            article=instance.article, deleted=False
        )
        # TODO: This was breaking things. SimplePlugin doesn't have a revision?
        p_revisions.update(article_revision=instance)


@disable_signal_for_loaddata
def on_simple_plugins_pre_save(**kwargs):
    instance = kwargs['instance']
    if kwargs.get('created', False):
    instance = kwargs["instance"]
    if kwargs.get("created", False):
        if not instance.article.current_revision:
            raise SimplePluginCreateError(
                "Article does not have a current_revision set.")
                "Article does not have a current_revision set."
            )
        new_revision = ArticleRevision()
        new_revision.inherit_predecessor(instance.article)
        new_revision.automatic_log = instance.get_logmessage()


@@ 256,7 262,7 @@ def on_simple_plugins_pre_save(**kwargs):

@disable_signal_for_loaddata
def on_article_plugin_post_save(**kwargs):
    articleplugin = kwargs['instance']
    articleplugin = kwargs["instance"]
    articleplugin.article.clear_cache()




@@ 264,7 270,7 @@ def on_article_plugin_post_save(**kwargs):
def on_reusable_plugin_pre_save(**kwargs):
    # Automatically make the original article the first one in the added
    # set
    instance = kwargs['instance']
    instance = kwargs["instance"]
    if not instance.article:
        articles = instance.articles.all()
        if articles.exists():


@@ 275,7 281,7 @@ def on_reusable_plugin_pre_save(**kwargs):
def on_revision_plugin_revision_post_save(**kwargs):
    # Automatically make the original article the first one in the added
    # set
    instance = kwargs['instance']
    instance = kwargs["instance"]
    if not instance.plugin.current_revision:
        # If I'm saved from Django admin, then plugin.current_revision is
        # me!


@@ 288,13 294,13 @@ def on_revision_plugin_revision_post_save(**kwargs):

@disable_signal_for_loaddata
def on_revision_plugin_revision_pre_save(**kwargs):
    instance = kwargs['instance']
    if kwargs.get('created', False):
    instance = kwargs["instance"]
    if kwargs.get("created", False):
        update_previous_revision = (
            not instance.previous_revision and
            instance.plugin and
            instance.plugin.current_revision and
            instance.plugin.current_revision != instance
            not instance.previous_revision
            and instance.plugin
            and instance.plugin.current_revision
            and instance.plugin.current_revision != instance
        )
        if update_previous_revision:
            instance.previous_revision = instance.plugin.current_revision


@@ 309,7 315,7 @@ def on_revision_plugin_revision_pre_save(**kwargs):

@disable_signal_for_loaddata
def on_reusable_plugin_post_save(**kwargs):
    reusableplugin = kwargs['instance']
    reusableplugin = kwargs["instance"]
    for article in reusableplugin.articles.all():
        article.clear_cache()


M src/wiki/models/urlpath.py => src/wiki/models/urlpath.py +86 -100
@@ 18,7 18,7 @@ from wiki.decorators import disable_signal_for_loaddata
from wiki.models.article import Article, ArticleForObject, ArticleRevision

__all__ = [
    'URLPath',
    "URLPath",
]




@@ 31,6 31,7 @@ class URLPath(MPTTModel):
    Strategy: Very few fields go here, as most has to be managed through an
    article's revision. As a side-effect, the URL resolution remains slim and swift.
    """

    # Tells django-wiki that permissions from a this object's article
    # should be inherited to children's articles. In this case, it's a static
    # property.. but you can also use a BooleanField.


@@ 44,8 45,8 @@ class URLPath(MPTTModel):

    articles = GenericRelation(
        ArticleForObject,
        content_type_field='content_type',
        object_id_field='object_id',
        content_type_field="content_type",
        object_id_field="object_id",
    )

    # Do NOT modify this field - it is updated with signals whenever


@@ 53,34 54,35 @@ class URLPath(MPTTModel):
    article = models.ForeignKey(
        Article,
        on_delete=models.CASCADE,
        verbose_name=_('article'),
        verbose_name=_("article"),
        help_text=_(
            "This field is automatically updated, but you need to populate "
            "it when creating a new URL path."
        )
        ),
    )

    SLUG_MAX_LENGTH = 50

    slug = models.SlugField(verbose_name=_('slug'), null=True, blank=True,
                            max_length=SLUG_MAX_LENGTH)
    slug = models.SlugField(
        verbose_name=_("slug"), null=True, blank=True, max_length=SLUG_MAX_LENGTH
    )
    site = models.ForeignKey(Site, on_delete=models.CASCADE)
    parent = TreeForeignKey(
        'self',
        "self",
        null=True,
        blank=True,
        on_delete=models.CASCADE,
        related_name='children',
        help_text=_("Position of URL path in the tree.")
        related_name="children",
        help_text=_("Position of URL path in the tree."),
    )
    moved_to = TreeForeignKey(
        'self',
        "self",
        verbose_name=_("Moved to"),
        help_text=_("Article path was moved to this location"),
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='moved_from'
        related_name="moved_from",
    )

    def __cached_ancestors(self):


@@ 99,8 101,7 @@ class URLPath(MPTTModel):
        if not self.pk or not self.get_ancestors().exists():
            self._cached_ancestors = []
        if not hasattr(self, "_cached_ancestors"):
            self._cached_ancestors = list(
                self.get_ancestors().select_related_common())
            self._cached_ancestors = list(self.get_ancestors().select_related_common())

        return self._cached_ancestors



@@ 108,8 109,7 @@ class URLPath(MPTTModel):
        self._cached_ancestors = ancestors

    # Python 2.5 compatible property constructor
    cached_ancestors = property(__cached_ancestors,
                                __cached_ancestors_setter)
    cached_ancestors = property(__cached_ancestors, __cached_ancestors_setter)

    def set_cached_ancestors_from_parent(self, parent):
        self.cached_ancestors = parent.cached_ancestors + [parent]


@@ 121,10 121,7 @@ class URLPath(MPTTModel):

        # All ancestors except roots
        ancestors = list(
            filter(
                lambda ancestor: ancestor.parent is not None,
                self.cached_ancestors
            )
            filter(lambda ancestor: ancestor.parent is not None, self.cached_ancestors)
        )
        slugs = [obj.slug if obj.slug else "" for obj in ancestors + [self]]



@@ 144,8 141,7 @@ class URLPath(MPTTModel):

    @transaction.atomic
    def _delete_subtree(self):
        for descendant in self.get_descendants(
                include_self=True).order_by("-level"):
        for descendant in self.get_descendants(include_self=True).order_by("-level"):
            descendant.article.delete()

    def delete_subtree(self):


@@ 163,13 159,9 @@ class URLPath(MPTTModel):
        # to get the result out anyway. This only takes one sql query
        no_paths = len(root_nodes)
        if no_paths == 0:
            raise NoRootURL(
                "You need to create a root article on site '%s'" %
                site)
            raise NoRootURL("You need to create a root article on site '%s'" % site)
        if no_paths > 1:
            raise MultipleRootURLs(
                "Somehow you have multiple roots on %s" %
                site)
            raise MultipleRootURLs("Somehow you have multiple roots on %s" % site)
        return root_nodes[0]

    class MPTTMeta:


@@ 180,29 172,28 @@ class URLPath(MPTTModel):
        return path if path else gettext("(root)")

    def delete(self, *args, **kwargs):
        assert not (self.parent and self.get_children()
                    ), "You cannot delete a root article with children."
        assert not (
            self.parent and self.get_children()
        ), "You cannot delete a root article with children."
        super().delete(*args, **kwargs)

    class Meta:
        verbose_name = _('URL path')
        verbose_name_plural = _('URL paths')
        unique_together = ('site', 'parent', 'slug')
        verbose_name = _("URL path")
        verbose_name_plural = _("URL paths")
        unique_together = ("site", "parent", "slug")

    def clean(self, *args, **kwargs):
        if self.slug and not self.parent:
            raise ValidationError(
                _('Sorry but you cannot have a root article with a slug.'))
                _("Sorry but you cannot have a root article with a slug.")
            )
        if not self.slug and self.parent:
            raise ValidationError(
                _('A non-root note must always have a slug.'))
            raise ValidationError(_("A non-root note must always have a slug."))
        if not self.parent:
            if URLPath.objects.root_nodes().filter(
                    site=self.site).exclude(
                    id=self.id):
            if URLPath.objects.root_nodes().filter(site=self.site).exclude(id=self.id):
                raise ValidationError(
                    _('There is already a root node on %s') %
                    self.site)
                    _("There is already a root node on %s") % self.site
                )

    @classmethod
    def get_by_path(cls, path, select_related=False):


@@ 222,18 213,18 @@ class URLPath(MPTTModel):
        if not path:
            return cls.root()

        slugs = path.split('/')
        slugs = path.split("/")
        level = 1
        parent = cls.root()
        for slug in slugs:
            if settings.URL_CASE_SENSITIVE:
                child = parent.get_children().select_related_common().get(
                    slug=slug)
                child = parent.get_children().select_related_common().get(slug=slug)
                child.cached_ancestors = parent.cached_ancestors + [parent]
                parent = child
            else:
                child = parent.get_children().select_related_common().get(
                    slug__iexact=slug)
                child = (
                    parent.get_children().select_related_common().get(slug__iexact=slug)
                )
                child.cached_ancestors = parent.cached_ancestors + [parent]
                parent = child
            level += 1


@@ 241,7 232,7 @@ class URLPath(MPTTModel):
        return parent

    def get_absolute_url(self):
        return reverse('wiki:get', kwargs={'path': self.path})
        return reverse("wiki:get", kwargs={"path": self.path})

    @classmethod
    def create_root(cls, site=None, title="Root", request=None, **kwargs):


@@ 264,15 255,16 @@ class URLPath(MPTTModel):
    @classmethod
    @transaction.atomic
    def create_urlpath(
            cls,
            parent,
            slug,
            site=None,
            title="Root",
            article_kwargs={},
            request=None,
            article_w_permissions=None,
            **revision_kwargs):
        cls,
        parent,
        slug,
        site=None,
        title="Root",
        article_kwargs={},
        request=None,
        article_w_permissions=None,
        **revision_kwargs
    ):
        """
        Utility function:
        Creates a new urlpath with an article and a new revision for the


@@ 283,27 275,18 @@ class URLPath(MPTTModel):
        if not site:
            site = Site.objects.get_current()
        article = Article(**article_kwargs)
        article.add_revision(ArticleRevision(title=title, **revision_kwargs),
                             save=True)
        article.add_revision(ArticleRevision(title=title, **revision_kwargs), save=True)
        article.save()
        newpath = cls.objects.create(
            site=site,
            parent=parent,
            slug=slug,
            article=article)
            site=site, parent=parent, slug=slug, article=article
        )
        article.add_object_relation(newpath)
        return newpath

    @classmethod
    def _create_urlpath_from_request(
            cls,
            request,
            perm_article,
            parent_urlpath,
            slug,
            title,
            content,
            summary):
        cls, request, perm_article, parent_urlpath, slug, title, content, summary
    ):
        """
        Creates a new URLPath, using meta data from ``request`` and copies in
        the permissions from ``perm_article``.


@@ 315,9 298,9 @@ class URLPath(MPTTModel):
        if not request.user.is_anonymous:
            user = request.user
            if settings.LOG_IPS_USERS:
                ip_address = request.META.get('REMOTE_ADDR', None)
                ip_address = request.META.get("REMOTE_ADDR", None)
        elif settings.LOG_IPS_ANONYMOUS:
            ip_address = request.META.get('REMOTE_ADDR', None)
            ip_address = request.META.get("REMOTE_ADDR", None)

        return cls.create_urlpath(
            parent_urlpath,


@@ 327,22 310,27 @@ class URLPath(MPTTModel):
            user_message=summary,
            user=user,
            ip_address=ip_address,
            article_kwargs={'owner': user,
                            'group': perm_article.group,
                            'group_read': perm_article.group_read,
                            'group_write': perm_article.group_write,
                            'other_read': perm_article.other_read,
                            'other_write': perm_article.other_write}
            article_kwargs={
                "owner": user,
                "group": perm_article.group,
                "group_read": perm_article.group_read,
                "group_write": perm_article.group_write,
                "other_read": perm_article.other_read,
                "other_write": perm_article.other_write,
            },
        )

    @classmethod
    def create_article(cls, *args, **kwargs):
        warnings.warn("Pending removal: URLPath.create_article renamed to create_urlpath", DeprecationWarning)
        warnings.warn(
            "Pending removal: URLPath.create_article renamed to create_urlpath",
            DeprecationWarning,
        )
        return cls.create_urlpath(*args, **kwargs)

    def get_ordered_children(self):
        """Return an ordered list of all chilren"""
        return self.children.order_by('slug')
        return self.children.order_by("slug")


######################################################


@@ 356,13 344,11 @@ urlpath_content_type = None
@disable_signal_for_loaddata
def on_article_relation_save(**kwargs):
    global urlpath_content_type
    instance = kwargs['instance']
    instance = kwargs["instance"]
    if not urlpath_content_type:
        urlpath_content_type = ContentType.objects.get_for_model(URLPath)
    if instance.content_type == urlpath_content_type:
        URLPath.objects.filter(
            id=instance.object_id).update(
            article=instance.article)
        URLPath.objects.filter(id=instance.object_id).update(article=instance.article)


post_save.connect(on_article_relation_save, ArticleForObject)


@@ 383,7 369,7 @@ def on_article_delete(instance, *args, **kwargs):
    # Get the Lost-and-found path or create a new one
    # Only create the lost-and-found article if it's necessary and such
    # that the lost-and-found article can be deleted without being recreated!
    ns = Namespace()   # nonlocal namespace backported to Python 2.x
    ns = Namespace()  # nonlocal namespace backported to Python 2.x
    ns.lost_and_found = None

    def get_lost_and_found():


@@ 391,32 377,32 @@ def on_article_delete(instance, *args, **kwargs):
            return ns.lost_and_found
        try:
            ns.lost_and_found = URLPath.objects.get(
                slug=settings.LOST_AND_FOUND_SLUG,
                parent=URLPath.root(),
                site=site)
                slug=settings.LOST_AND_FOUND_SLUG, parent=URLPath.root(), site=site
            )
        except URLPath.DoesNotExist:
            article = Article(group_read=True,
                              group_write=False,
                              other_read=False,
                              other_write=False)
            article = Article(
                group_read=True, group_write=False, other_read=False, other_write=False
            )
            article.add_revision(
                ArticleRevision(
                    content=_(
                        'Articles who lost their parents\n'
                        '===============================\n\n'
                        'The children of this article have had their parents deleted. You should probably find a new home for them.'),
                    title=_("Lost and found")))
                        "Articles who lost their parents\n"
                        "===============================\n\n"
                        "The children of this article have had their parents deleted. You should probably find a new home for them."
                    ),
                    title=_("Lost and found"),
                )
            )
            ns.lost_and_found = URLPath.objects.create(
                slug=settings.LOST_AND_FOUND_SLUG,
                parent=URLPath.root(),
                site=site,
                article=article)
                article=article,
            )
            article.add_object_relation(ns.lost_and_found)
        return ns.lost_and_found

    for urlpath in URLPath.objects.filter(
            articles__article=instance,
            site=site):
    for urlpath in URLPath.objects.filter(articles__article=instance, site=site):
        # Delete the children
        for child in urlpath.get_children():
            child.move_to(get_lost_and_found())

M src/wiki/plugins/attachments/__init__.py => src/wiki/plugins/attachments/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.attachments.apps.AttachmentsConfig'
default_app_config = "wiki.plugins.attachments.apps.AttachmentsConfig"

M src/wiki/plugins/attachments/admin.py => src/wiki/plugins/attachments/admin.py +1 -1
@@ 6,7 6,7 @@ from . import models
class AttachmentRevisionAdmin(admin.TabularInline):
    model = models.AttachmentRevision
    extra = 1
    fields = ('file', 'user', 'user_message')
    fields = ("file", "user", "user_message")


class AttachmentAdmin(admin.ModelAdmin):

M src/wiki/plugins/attachments/apps.py => src/wiki/plugins/attachments/apps.py +2 -2
@@ 3,6 3,6 @@ from django.utils.translation import gettext_lazy as _


class AttachmentsConfig(AppConfig):
    name = 'wiki.plugins.attachments'
    name = "wiki.plugins.attachments"
    verbose_name = _("Wiki attachments")
    label = 'wiki_attachments'
    label = "wiki_attachments"

M src/wiki/plugins/attachments/forms.py => src/wiki/plugins/attachments/forms.py +46 -36
@@ 12,19 12,19 @@ from wiki.plugins.attachments.models import IllegalFileExtension
class AttachmentForm(forms.ModelForm):

    description = forms.CharField(
        label=_('Description'),
        help_text=_('A short summary of what the file contains'),
        required=False
        label=_("Description"),
        help_text=_("A short summary of what the file contains"),
        required=False,
    )

    def __init__(self, *args, **kwargs):
        self.article = kwargs.pop('article', None)
        self.request = kwargs.pop('request', None)
        self.attachment = kwargs.pop('attachment', None)
        self.article = kwargs.pop("article", None)
        self.request = kwargs.pop("request", None)
        self.attachment = kwargs.pop("attachment", None)
        super().__init__(*args, **kwargs)

    def clean_file(self):
        uploaded_file = self.cleaned_data.get('file', None)
        uploaded_file = self.cleaned_data.get("file", None)
        if uploaded_file:
            try:
                models.extension_allowed(uploaded_file.name)


@@ 33,12 33,12 @@ class AttachmentForm(forms.ModelForm):
        return uploaded_file

    def save(self, *args, **kwargs):
        commit = kwargs.get('commit', True)
        commit = kwargs.get("commit", True)
        attachment_revision = super().save(commit=False)

        # Added because of AttachmentArchiveForm removing file from fields
        # should be more elegant
        attachment_revision.file = self.cleaned_data['file']
        attachment_revision.file = self.cleaned_data["file"]
        if not self.attachment:
            attachment = models.Attachment()
            attachment.article = self.article


@@ 56,15 56,19 @@ class AttachmentForm(forms.ModelForm):

    class Meta:
        model = models.AttachmentRevision
        fields = ('file', 'description',)
        fields = (
            "file",
            "description",
        )


class AttachmentReplaceForm(AttachmentForm):

    replace = forms.BooleanField(
        label=_('Remove previous'),
        help_text=_('Remove previous attachment revisions and their files (to '
                    'save space)?'),
        label=_("Remove previous"),
        help_text=_(
            "Remove previous attachment revisions and their files (to " "save space)?"
        ),
        required=False,
    )



@@ 72,26 76,27 @@ class AttachmentReplaceForm(AttachmentForm):
class AttachmentArchiveForm(AttachmentForm):

    file = forms.FileField(  # @ReservedAssignment
        label=_('File or zip archive'),
        required=True
        label=_("File or zip archive"), required=True
    )

    unzip_archive = forms.BooleanField(
        label=_('Unzip file'),
        label=_("Unzip file"),
        help_text=_(
            'Create individual attachments for files in a .zip file - directories do not work.'),
        required=False)
            "Create individual attachments for files in a .zip file - directories do not work."
        ),
        required=False,
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        ordered_fields = ['unzip_archive', 'file']
        self.fields.keyOrder = ordered_fields + [k
                                                 for k in self.fields.keys()
                                                 if k not in ordered_fields]
        ordered_fields = ["unzip_archive", "file"]
        self.fields.keyOrder = ordered_fields + [
            k for k in self.fields.keys() if k not in ordered_fields
        ]

    def clean_file(self):
        uploaded_file = self.cleaned_data.get('file', None)
        if uploaded_file and self.cleaned_data.get('unzip_archive', False):
        uploaded_file = self.cleaned_data.get("file", None)
        if uploaded_file and self.cleaned_data.get("unzip_archive", False):
            try:
                self.zipfile = zipfile.ZipFile(uploaded_file.file, mode="r")
                for zipinfo in self.zipfile.filelist:


@@ 109,20 114,21 @@ class AttachmentArchiveForm(AttachmentForm):
        super().clean()
        if not can_moderate(self.article, self.request.user):
            raise forms.ValidationError(
                gettext("User not allowed to moderate this article"))
                gettext("User not allowed to moderate this article")
            )
        return self.cleaned_data

    def save(self, *args, **kwargs):

        # This is not having the intended effect
        if 'file' not in self._meta.fields:
            self._meta.fields.append('file')
        if "file" not in self._meta.fields:
            self._meta.fields.append("file")

        if self.cleaned_data['unzip_archive']:
        if self.cleaned_data["unzip_archive"]:
            new_attachments = []
            try:
                for zipinfo in self.zipfile.filelist:
                    f = tempfile.NamedTemporaryFile(mode='r+w')
                    f = tempfile.NamedTemporaryFile(mode="r+w")
                    f.write(self.zipfile.read(zipinfo.filename))
                    f = File(f, name=zipinfo.filename)
                    try:


@@ 133,7 139,9 @@ class AttachmentArchiveForm(AttachmentForm):
                        attachment.articles.add(self.article)
                        attachment_revision = models.AttachmentRevision()
                        attachment_revision.file = f
                        attachment_revision.description = self.cleaned_data['description']
                        attachment_revision.description = self.cleaned_data[
                            "description"
                        ]
                        attachment_revision.attachment = attachment
                        attachment_revision.set_from_request(self.request)
                        attachment_revision.save()


@@ 148,22 156,24 @@ class AttachmentArchiveForm(AttachmentForm):
            return super().save(*args, **kwargs)

    class Meta(AttachmentForm.Meta):
        fields = ['description', ]
        fields = [
            "description",
        ]


class DeleteForm(forms.Form):
    """This form is both used for dereferencing and deleting attachments"""
    confirm = forms.BooleanField(label=_('Yes I am sure...'), required=False)

    confirm = forms.BooleanField(label=_("Yes I am sure..."), required=False)

    def clean_confirm(self):
        if not self.cleaned_data['confirm']:
            raise forms.ValidationError(gettext('You are not sure enough!'))
        if not self.cleaned_data["confirm"]:
            raise forms.ValidationError(gettext("You are not sure enough!"))
        return True


class SearchForm(forms.Form):

    query = forms.CharField(
        label="",
        widget=forms.TextInput(attrs={'class': 'search-query form-control'}),
        label="", widget=forms.TextInput(attrs={"class": "search-query form-control"}),
    )

M src/wiki/plugins/attachments/markdown_extensions.py => src/wiki/plugins/attachments/markdown_extensions.py +25 -26
@@ 8,8 8,9 @@ from wiki.core.permissions import can_read
from wiki.plugins.attachments import models

ATTACHMENT_RE = re.compile(
    r'(?P<before>.*)\[( *((attachment\:(?P<id>[0-9]+))|(title\:\"(?P<title>[^\"]+)\")|(?P<size>size)))+\](?P<after>.*)',
    re.IGNORECASE)
    r"(?P<before>.*)\[( *((attachment\:(?P<id>[0-9]+))|(title\:\"(?P<title>[^\"]+)\")|(?P<size>size)))+\](?P<after>.*)",
    re.IGNORECASE,
)


class AttachmentExtension(markdown.Extension):


@@ 19,9 20,8 @@ class AttachmentExtension(markdown.Extension):
    def extendMarkdown(self, md):
        """ Insert AbbrPreprocessor before ReferencePreprocessor. """
        md.preprocessors.add(
            'dw-attachments',
            AttachmentPreprocessor(md),
            '>html_block')
            "dw-attachments", AttachmentPreprocessor(md), ">html_block"
        )


class AttachmentPreprocessor(markdown.preprocessors.Preprocessor):


@@ 36,23 36,24 @@ class AttachmentPreprocessor(markdown.preprocessors.Preprocessor):
                new_text.append(line)
                continue

            attachment_id = m.group('id').strip()
            title = m.group('title')
            size = m.group('size')
            before = self.run([m.group('before')])[0]
            after = self.run([m.group('after')])[0]
            attachment_id = m.group("id").strip()
            title = m.group("title")
            size = m.group("size")
            before = self.run([m.group("before")])[0]
            after = self.run([m.group("after")])[0]
            try:
                attachment = models.Attachment.objects.get(
                    articles__current_revision__deleted=False,
                    id=attachment_id, current_revision__deleted=False,
                    articles=self.markdown.article
                    id=attachment_id,
                    current_revision__deleted=False,
                    articles=self.markdown.article,
                )
                url = reverse(
                    'wiki:attachments_download',
                    "wiki:attachments_download",
                    kwargs={
                        'article_id': self.markdown.article.id,
                        'attachment_id': attachment.id,
                    }
                        "article_id": self.markdown.article.id,
                        "attachment_id": attachment.id,
                    },
                )

                # The readability of the attachment is decided relative


@@ 68,17 69,16 @@ class AttachmentPreprocessor(markdown.preprocessors.Preprocessor):
                if size:
                    size = attachment.current_revision.get_size()

                attachment_can_read = can_read(
                    self.markdown.article, article_owner)
                attachment_can_read = can_read(self.markdown.article, article_owner)
                html = render_to_string(
                    "wiki/plugins/attachments/render.html",
                    context={
                        'url': url,
                        'filename': attachment.original_filename,
                        'title': title,
                        'size': size,
                        'attachment_can_read': attachment_can_read,
                    }
                        "url": url,
                        "filename": attachment.original_filename,
                        "title": title,
                        "size": size,
                        "attachment_can_read": attachment_can_read,
                    },
                )
                line = self.markdown.htmlStash.store(html)
            except models.Attachment.DoesNotExist:


@@ 87,8 87,7 @@ class AttachmentPreprocessor(markdown.preprocessors.Preprocessor):
                    """#{} is deleted.</span>"""
                ).format(attachment_id)
                line = line.replace(
                    '[' + m.group(2) + ']',
                    self.markdown.htmlStash.store(html)
                    "[" + m.group(2) + "]", self.markdown.htmlStash.store(html)
                )
            new_text.append(before + line + after)
        return new_text

M src/wiki/plugins/attachments/migrations/0001_initial.py => src/wiki/plugins/attachments/migrations/0001_initial.py +100 -29
@@ 8,53 8,124 @@ from django.db.models.fields import GenericIPAddressField as IPAddressField
class Migration(migrations.Migration):

    dependencies = [
        ('wiki', '0001_initial'),
        ("wiki", "0001_initial"),
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
    ]

    operations = [
        migrations.CreateModel(
            name='Attachment',
            name="Attachment",
            fields=[
                ('reusableplugin_ptr', models.OneToOneField(parent_link=True, serialize=False, primary_key=True, to='wiki.ReusablePlugin', auto_created=True, on_delete=models.CASCADE)),
                ('original_filename', models.CharField(max_length=256, verbose_name='original filename', blank=True, null=True)),
                (
                    "reusableplugin_ptr",
                    models.OneToOneField(
                        parent_link=True,
                        serialize=False,
                        primary_key=True,
                        to="wiki.ReusablePlugin",
                        auto_created=True,
                        on_delete=models.CASCADE,
                    ),
                ),
                (
                    "original_filename",
                    models.CharField(
                        max_length=256,
                        verbose_name="original filename",
                        blank=True,
                        null=True,
                    ),
                ),
            ],
            options={
                'verbose_name': 'attachment',
                'verbose_name_plural': 'attachments',
                "verbose_name": "attachment",
                "verbose_name_plural": "attachments",
            },
            bases=('wiki.reusableplugin',),
            bases=("wiki.reusableplugin",),
        ),
        migrations.CreateModel(
            name='AttachmentRevision',
            name="AttachmentRevision",
            fields=[
                ('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)),
                ('revision_number', models.IntegerField(verbose_name='revision number', editable=False)),
                ('user_message', models.TextField(blank=True)),
                ('automatic_log', models.TextField(editable=False, blank=True)),
                ('ip_address', IPAddressField(editable=False, verbose_name='IP address', blank=True, null=True)),
                ('modified', models.DateTimeField(auto_now=True)),
                ('created', models.DateTimeField(auto_now_add=True)),
                ('deleted', models.BooleanField(default=False, verbose_name='deleted')),
                ('locked', models.BooleanField(default=False, verbose_name='locked')),
                ('file', models.FileField(max_length=255, verbose_name='file', upload_to=wiki.plugins.attachments.models.upload_path)),
                ('description', models.TextField(blank=True)),
                ('attachment', models.ForeignKey(to='wiki_attachments.Attachment', on_delete=models.CASCADE)),
                ('previous_revision', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.SET_NULL, to='wiki_attachments.AttachmentRevision', null=True)),
                ('user', models.ForeignKey(blank=True, verbose_name='user', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)),
                (
                    "id",
                    models.AutoField(
                        serialize=False,
                        primary_key=True,
                        verbose_name="ID",
                        auto_created=True,
                    ),
                ),
                (
                    "revision_number",
                    models.IntegerField(verbose_name="revision number", editable=False),
                ),
                ("user_message", models.TextField(blank=True)),
                ("automatic_log", models.TextField(editable=False, blank=True)),
                (
                    "ip_address",
                    IPAddressField(
                        editable=False, verbose_name="IP address", blank=True, null=True
                    ),
                ),
                ("modified", models.DateTimeField(auto_now=True)),
                ("created", models.DateTimeField(auto_now_add=True)),
                ("deleted", models.BooleanField(default=False, verbose_name="deleted")),
                ("locked", models.BooleanField(default=False, verbose_name="locked")),
                (
                    "file",
                    models.FileField(
                        max_length=255,
                        verbose_name="file",
                        upload_to=wiki.plugins.attachments.models.upload_path,
                    ),
                ),
                ("description", models.TextField(blank=True)),
                (
                    "attachment",
                    models.ForeignKey(
                        to="wiki_attachments.Attachment", on_delete=models.CASCADE
                    ),
                ),
                (
                    "previous_revision",
                    models.ForeignKey(
                        blank=True,
                        on_delete=django.db.models.deletion.SET_NULL,
                        to="wiki_attachments.AttachmentRevision",
                        null=True,
                    ),
                ),
                (
                    "user",
                    models.ForeignKey(
                        blank=True,
                        verbose_name="user",
                        on_delete=django.db.models.deletion.SET_NULL,
                        to=settings.AUTH_USER_MODEL,
                        null=True,
                    ),
                ),
            ],
            options={
                'ordering': ('created',),
                'get_latest_by': 'revision_number',
                'verbose_name': 'attachment revision',
                'verbose_name_plural': 'attachment revisions',
                "ordering": ("created",),
                "get_latest_by": "revision_number",
                "verbose_name": "attachment revision",
                "verbose_name_plural": "attachment revisions",
            },
            bases=(models.Model,),
        ),
        migrations.AddField(
            model_name='attachment',
            name='current_revision',
            field=models.OneToOneField(to='wiki_attachments.AttachmentRevision', blank=True, verbose_name='current revision', related_name='current_set', help_text='The revision of this attachment currently in use (on all articles using the attachment)', null=True, on_delete=models.CASCADE),
            model_name="attachment",
            name="current_revision",
            field=models.OneToOneField(
                to="wiki_attachments.AttachmentRevision",
                blank=True,
                verbose_name="current revision",
                related_name="current_set",
                help_text="The revision of this attachment currently in use (on all articles using the attachment)",
                null=True,
                on_delete=models.CASCADE,
            ),
            preserve_default=True,
        ),
    ]

M src/wiki/plugins/attachments/migrations/0002_auto_20151118_1816.py => src/wiki/plugins/attachments/migrations/0002_auto_20151118_1816.py +3 -5
@@ 6,16 6,14 @@ class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('wiki_attachments', '0001_initial'),
        ("wiki_attachments", "0001_initial"),
    ]

    operations = [
        migrations.AlterModelTable(
            name='attachment',
            table='wiki_attachments_attachment',
            name="attachment", table="wiki_attachments_attachment",
        ),
        migrations.AlterModelTable(
            name='attachmentrevision',
            table='wiki_attachments_attachmentrevision',
            name="attachmentrevision", table="wiki_attachments_attachmentrevision",
        ),
    ]

M src/wiki/plugins/attachments/models.py => src/wiki/plugins/attachments/models.py +56 -47
@@ 15,6 15,7 @@ from . import settings
class IllegalFileExtension(Exception):

    """File extension on upload is not allowed"""

    pass




@@ 23,17 24,20 @@ class Attachment(ReusablePlugin):
    objects = managers.ArticleFkManager()

    current_revision = models.OneToOneField(
        'AttachmentRevision', verbose_name=_('current revision'),
        blank=True, null=True, related_name='current_set',
        "AttachmentRevision",
        verbose_name=_("current revision"),
        blank=True,
        null=True,
        related_name="current_set",
        on_delete=models.CASCADE,
        help_text=_(
            'The revision of this attachment currently in use (on all articles using the attachment)'),)
            "The revision of this attachment currently in use (on all articles using the attachment)"
        ),
    )

    original_filename = models.CharField(
        max_length=256,
        verbose_name=_('original filename'),
        blank=True,
        null=True)
        max_length=256, verbose_name=_("original filename"), blank=True, null=True
    )

    def can_write(self, user):
        if not settings.ANONYMOUS and (not user or user.is_anonymous):


@@ 44,16 48,19 @@ class Attachment(ReusablePlugin):
        return self.can_write(user)

    class Meta:
        verbose_name = _('attachment')
        verbose_name_plural = _('attachments')
        verbose_name = _("attachment")
        verbose_name_plural = _("attachments")
        # Matches label of upcoming 0.1 release
        db_table = 'wiki_attachments_attachment'
        db_table = "wiki_attachments_attachment"

    def __str__(self):
        from wiki.models import Article

        try:
            return "%s: %s" % (
                self.article.current_revision.title, self.original_filename)
                self.article.current_revision.title,
                self.original_filename,
            )
        except Article.DoesNotExist:
            return "Attachment for non-existing article"



@@ 64,18 71,14 @@ def extension_allowed(filename):
    except IndexError:
        # No extension
        raise IllegalFileExtension(
            gettext("No file extension found in filename. That's not okay!"))
    if not extension.lower() in map(
            lambda x: x.lower(),
            settings.FILE_EXTENSIONS):
            gettext("No file extension found in filename. That's not okay!")
        )
    if not extension.lower() in map(lambda x: x.lower(), settings.FILE_EXTENSIONS):
        raise IllegalFileExtension(
            gettext(
                "The following filename is illegal: {filename:s}. Extension "
                "has to be one of {extensions:s}"
            ).format(
                filename=filename,
                extensions=", ".join(settings.FILE_EXTENSIONS)
            )
            ).format(filename=filename, extensions=", ".join(settings.FILE_EXTENSIONS))
        )

    return extension


@@ 89,45 92,46 @@ def upload_path(instance, filename):
        original_extension = instance.attachment.original_filename.split(".")[-1]
        if not extension.lower() == original_extension:
            raise IllegalFileExtension(
                "File extension has to be '%s', not '%s'." %
                (original_extension, extension.lower()))
                "File extension has to be '%s', not '%s'."
                % (original_extension, extension.lower())
            )
    elif instance.attachment:
        instance.attachment.original_filename = filename

    upload_path = settings.UPLOAD_PATH
    upload_path = upload_path.replace(
        '%aid', str(
            instance.attachment.article.id))
    upload_path = upload_path.replace("%aid", str(instance.attachment.article.id))
    if settings.UPLOAD_PATH_OBSCURIFY:
        import random
        import hashlib
        m = hashlib.md5(
            str(random.randint(0, 100000000000000)).encode('ascii'))

        m = hashlib.md5(str(random.randint(0, 100000000000000)).encode("ascii"))
        upload_path = os.path.join(upload_path, m.hexdigest())

    if settings.APPEND_EXTENSION:
        filename += '.upload'
        filename += ".upload"
    return os.path.join(upload_path, filename)


class AttachmentRevision(BaseRevisionMixin, models.Model):

    attachment = models.ForeignKey('Attachment', on_delete=models.CASCADE)
    attachment = models.ForeignKey("Attachment", on_delete=models.CASCADE)

    file = models.FileField(upload_to=upload_path,  # @ReservedAssignment
                            max_length=255,
                            verbose_name=_('file'),
                            storage=settings.STORAGE_BACKEND)
    file = models.FileField(
        upload_to=upload_path,  # @ReservedAssignment
        max_length=255,
        verbose_name=_("file"),
        storage=settings.STORAGE_BACKEND,
    )

    description = models.TextField(blank=True)

    class Meta:
        verbose_name = _('attachment revision')
        verbose_name_plural = _('attachment revisions')
        ordering = ('created',)
        get_latest_by = 'revision_number'
        verbose_name = _("attachment revision")
        verbose_name_plural = _("attachment revisions")
        ordering = ("created",)
        get_latest_by = "revision_number"
        # Matches label of upcoming 0.1 release
        db_table = 'wiki_attachments_attachmentrevision'
        db_table = "wiki_attachments_attachmentrevision"

    def get_filename(self):
        """Used to retrieve the filename of a revision.


@@ 147,9 151,11 @@ class AttachmentRevision(BaseRevisionMixin, models.Model):
            return None

    def __str__(self):
        return "%s: %s (r%d)" % (self.attachment.article.current_revision.title,
                                 self.attachment.original_filename,
                                 self.revision_number)
        return "%s: %s (r%d)" % (
            self.attachment.article.current_revision.title,
            self.attachment.original_filename,
            self.revision_number,
        )


@disable_signal_for_loaddata


@@ 172,7 178,10 @@ def on_revision_delete(instance, *args, **kwargs):
    for depth in range(0, max_depth):
        delete_path = "/".join(path[:-depth] if depth > 0 else path)
        try:
            if len(os.listdir(os.path.join(django_settings.MEDIA_ROOT, delete_path))) == 0:
            if (
                len(os.listdir(os.path.join(django_settings.MEDIA_ROOT, delete_path)))
                == 0
            ):
                os.rmdir(delete_path)
        except OSError:
            # Raised by os.listdir if directory is missing


@@ 181,13 190,13 @@ def on_revision_delete(instance, *args, **kwargs):

@disable_signal_for_loaddata
def on_attachment_revision_pre_save(**kwargs):
    instance = kwargs['instance']
    instance = kwargs["instance"]
    if instance._state.adding:
        update_previous_revision = (
            not instance.previous_revision and
            instance.attachment and
            instance.attachment.current_revision and
            instance.attachment.current_revision != instance
            not instance.previous_revision
            and instance.attachment
            and instance.attachment.current_revision
            and instance.attachment.current_revision != instance
        )
        if update_previous_revision:
            instance.previous_revision = instance.attachment.current_revision


@@ 204,7 213,7 @@ def on_attachment_revision_pre_save(**kwargs):

@disable_signal_for_loaddata
def on_attachment_revision_post_save(**kwargs):
    instance = kwargs['instance']
    instance = kwargs["instance"]
    if not instance.attachment.current_revision:
        # If I'm saved from Django admin, then article.current_revision is
        # me!

M src/wiki/plugins/attachments/settings.py => src/wiki/plugins/attachments/settings.py +14 -20
@@ 14,9 14,8 @@ SLUG = "attachments"
#: ``WIKI_ATTACHMENTS_ANONYMOUS`` can override this, otherwise the default
#: in ``wiki.conf.settings`` is used.
ANONYMOUS = getattr(
    django_settings,
    'WIKI_ATTACHMENTS_ANONYMOUS',
    wiki_settings.ANONYMOUS_UPLOAD)
    django_settings, "WIKI_ATTACHMENTS_ANONYMOUS", wiki_settings.ANONYMOUS_UPLOAD
)

# Maximum file sizes: Please use something like LimitRequestBody on
# your web server.


@@ 27,18 26,16 @@ ANONYMOUS = getattr(
#: Actually, you can completely disable serving it, if you want. Files are
#: sent to the user through a Django view that reads and streams a file.
UPLOAD_PATH = getattr(
    django_settings,
    'WIKI_ATTACHMENTS_PATH',
    'wiki/attachments/%aid/')
    django_settings, "WIKI_ATTACHMENTS_PATH", "wiki/attachments/%aid/"
)

#: Should the upload path be obscurified? If so, a random hash will be
#: added to the path such that someone can not guess the location of files
#: (if you have restricted permissions and the files are still located
#: within the web server's file system).
UPLOAD_PATH_OBSCURIFY = getattr(
    django_settings,
    'WIKI_ATTACHMENTS_PATH_OBSCURIFY',
    True)
    django_settings, "WIKI_ATTACHMENTS_PATH_OBSCURIFY", True
)

#: Allowed extensions for attachments, empty to disallow uploads completely.
#: If ``WIKI_ATTACHMENTS_APPEND_EXTENSION`` files are saved with an appended


@@ 48,29 45,25 @@ UPLOAD_PATH_OBSCURIFY = getattr(
#: to allow. For your own safety.
#: Note: this setting is called WIKI_ATTACHMENTS_EXTENTIONS not WIKI_ATTACHMENTS_FILE_EXTENTIONS
FILE_EXTENSIONS = getattr(
    django_settings, 'WIKI_ATTACHMENTS_EXTENSIONS',
    ['pdf', 'doc', 'odt', 'docx', 'txt'])
    django_settings, "WIKI_ATTACHMENTS_EXTENSIONS", ["pdf", "doc", "odt", "docx", "txt"]
)

#: Storage backend to use, default is to use the same as the rest of the
#: wiki, which is set in ``WIKI_STORAGE_BACKEND``, but you can override it
#: with ``WIKI_ATTACHMENTS_STORAGE_BACKEND``.
STORAGE_BACKEND = getattr(
    django_settings,
    'WIKI_ATTACHMENTS_STORAGE_BACKEND',
    wiki_settings.STORAGE_BACKEND)
    django_settings, "WIKI_ATTACHMENTS_STORAGE_BACKEND", wiki_settings.STORAGE_BACKEND
)

#: Store files always with an appended .upload extension to be sure that
#: something nasty does not get executed on the server. SAFETY FIRST!
APPEND_EXTENSION = getattr(
    django_settings,
    'WIKI_ATTACHMENTS_APPEND_EXTENSION',
    True)
APPEND_EXTENSION = getattr(django_settings, "WIKI_ATTACHMENTS_APPEND_EXTENSION", True)

#: Important for e.g. S3 backends: If your storage backend does not have a .path
#: attribute for the file, but only a .url attribute, you should use False.
#: This will reveal the direct download URL so it does not work perfectly for
#: files you wish to be kept private.
USE_LOCAL_PATH = getattr(django_settings, 'WIKI_ATTACHMENTS_LOCAL_PATH', True)
USE_LOCAL_PATH = getattr(django_settings, "WIKI_ATTACHMENTS_LOCAL_PATH", True)

if (not USE_LOCAL_PATH) and APPEND_EXTENSION:
    raise ImproperlyConfigured(


@@ 78,4 71,5 @@ if (not USE_LOCAL_PATH) and APPEND_EXTENSION:
        "You have configured to append .upload and not use local paths. That won't "
        "work as all your attachments will be stored and sent with a .upload "
        "extension. You have to trust your storage backend to be safe for storing"
        "the extensions you have allowed.")
        "the extensions you have allowed."
    )

M src/wiki/plugins/attachments/urls.py => src/wiki/plugins/attachments/urls.py +32 -20
@@ 2,31 2,43 @@ from django.urls import re_path
from wiki.plugins.attachments import views

urlpatterns = [
    re_path(r'^$',
        views.AttachmentView.as_view(),
        name='attachments_index'),
    re_path(r'^search/$',
        views.AttachmentSearchView.as_view(),
        name='attachments_search'),
    re_path(r'^add/(?P<attachment_id>[0-9]+)/$',
    re_path(r"^$", views.AttachmentView.as_view(), name="attachments_index"),
    re_path(
        r"^search/$", views.AttachmentSearchView.as_view(), name="attachments_search"
    ),
    re_path(
        r"^add/(?P<attachment_id>[0-9]+)/$",
        views.AttachmentAddView.as_view(),
        name='attachments_add'),
    re_path(r'^replace/(?P<attachment_id>[0-9]+)/$',
        name="attachments_add",
    ),
    re_path(
        r"^replace/(?P<attachment_id>[0-9]+)/$",
        views.AttachmentReplaceView.as_view(),
        name='attachments_replace'),
    re_path(r'^history/(?P<attachment_id>[0-9]+)/$',
        name="attachments_replace",
    ),
    re_path(
        r"^history/(?P<attachment_id>[0-9]+)/$",
        views.AttachmentHistoryView.as_view(),
        name='attachments_history'),
    re_path(r'^download/(?P<attachment_id>[0-9]+)/$',
        name="attachments_history",
    ),
    re_path(
        r"^download/(?P<attachment_id>[0-9]+)/$",
        views.AttachmentDownloadView.as_view(),
        name='attachments_download'),
    re_path(r'^delete/(?P<attachment_id>[0-9]+)/$',
        name="attachments_download",
    ),
    re_path(
        r"^delete/(?P<attachment_id>[0-9]+)/$",
        views.AttachmentDeleteView.as_view(),
        name='attachments_delete'),
    re_path(r'^download/(?P<attachment_id>[0-9]+)/revision/(?P<revision_id>[0-9]+)/$',
        name="attachments_delete",
    ),
    re_path(
        r"^download/(?P<attachment_id>[0-9]+)/revision/(?P<revision_id>[0-9]+)/$",
        views.AttachmentDownloadView.as_view(),
        name='attachments_download'),
    re_path(r'^change/(?P<attachment_id>[0-9]+)/revision/(?P<revision_id>[0-9]+)/$',
        name="attachments_download",
    ),
    re_path(
        r"^change/(?P<attachment_id>[0-9]+)/revision/(?P<revision_id>[0-9]+)/$",
        views.AttachmentChangeRevisionView.as_view(),
        name='attachments_revision_change'),
        name="attachments_revision_change",
    ),
]

M src/wiki/plugins/attachments/views.py => src/wiki/plugins/attachments/views.py +143 -125
@@ 21,16 21,19 @@ class AttachmentView(ArticleMixin, FormView):
    @method_decorator(get_article(can_read=True))
    def dispatch(self, request, article, *args, **kwargs):
        if article.can_moderate(request.user):
            self.attachments = models.Attachment.objects.filter(
                articles=article, current_revision__deleted=False
            ).exclude(
                current_revision__file=None
            ).order_by('original_filename')
            self.attachments = (
                models.Attachment.objects.filter(
                    articles=article, current_revision__deleted=False
                )
                .exclude(current_revision__file=None)
                .order_by("original_filename")
            )

            self.form_class = forms.AttachmentArchiveForm
        else:
            self.attachments = models.Attachment.objects.active().filter(
                articles=article)
                articles=article
            )

        # Fixing some weird transaction issue caused by adding commit_manually
        # to form_valid


@@ 38,47 41,52 @@ class AttachmentView(ArticleMixin, FormView):

    def form_valid(self, form):

        if (self.request.user.is_anonymous and not settings.ANONYMOUS or
                not self.article.can_write(self.request.user) or
                self.article.current_revision.locked):
        if (
            self.request.user.is_anonymous
            and not settings.ANONYMOUS
            or not self.article.can_write(self.request.user)
            or self.article.current_revision.locked
        ):
            return response_forbidden(self.request, self.article, self.urlpath)

        attachment_revision = form.save()
        if isinstance(attachment_revision, list):
            messages.success(
                self.request, _('Successfully added: %s') %
                (", ".join(
                    [ar.get_filename() for ar in attachment_revision])))
                self.request,
                _("Successfully added: %s")
                % (", ".join([ar.get_filename() for ar in attachment_revision])),
            )
        else:
            messages.success(
                self.request,
                _('%s was successfully added.') %
                attachment_revision.get_filename())
                _("%s was successfully added.") % attachment_revision.get_filename(),
            )
        self.article.clear_cache()

        return redirect(
            "wiki:attachments_index",
            path=self.urlpath.path,
            article_id=self.article.id)
            "wiki:attachments_index", path=self.urlpath.path, article_id=self.article.id
        )

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['article'] = self.article
        kwargs['request'] = self.request
        kwargs["article"] = self.article
        kwargs["request"] = self.request
        return kwargs

    def get_context_data(self, **kwargs):
        # Needed since Django 1.9 because get_context_data is no longer called
        # with the form instance
        if 'form' not in kwargs:
            kwargs['form'] = self.get_form()
        kwargs['attachments'] = self.attachments
        kwargs['deleted_attachments'] = models.Attachment.objects.filter(
            articles=self.article,
            current_revision__deleted=True)
        kwargs['search_form'] = forms.SearchForm()
        kwargs['selected_tab'] = 'attachments'
        kwargs['anonymous_disallowed'] = self.request.user.is_anonymous and not settings.ANONYMOUS
        if "form" not in kwargs:
            kwargs["form"] = self.get_form()
        kwargs["attachments"] = self.attachments
        kwargs["deleted_attachments"] = models.Attachment.objects.filter(
            articles=self.article, current_revision__deleted=True
        )
        kwargs["search_form"] = forms.SearchForm()
        kwargs["selected_tab"] = "attachments"
        kwargs["anonymous_disallowed"] = (
            self.request.user.is_anonymous and not settings.ANONYMOUS
        )
        return super().get_context_data(**kwargs)




@@ 90,21 98,20 @@ class AttachmentHistoryView(ArticleMixin, TemplateView):
    def dispatch(self, request, article, attachment_id, *args, **kwargs):
        if article.can_moderate(request.user):
            self.attachment = get_object_or_404(
                models.Attachment,
                id=attachment_id,
                articles=article)
                models.Attachment, id=attachment_id, articles=article
            )
        else:
            self.attachment = get_object_or_404(
                models.Attachment.objects.active(),
                id=attachment_id,
                articles=article)
                models.Attachment.objects.active(), id=attachment_id, articles=article
            )
        return super().dispatch(request, article, *args, **kwargs)

    def get_context_data(self, **kwargs):
        kwargs['attachment'] = self.attachment
        kwargs['revisions'] = self.attachment.attachmentrevision_set.all().order_by(
            '-revision_number')
        kwargs['selected_tab'] = 'attachments'
        kwargs["attachment"] = self.attachment
        kwargs["revisions"] = self.attachment.attachmentrevision_set.all().order_by(
            "-revision_number"
        )
        kwargs["selected_tab"] = "attachments"
        return super().get_context_data(**kwargs)




@@ 116,18 123,16 @@ class AttachmentReplaceView(ArticleMixin, FormView):
    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, attachment_id, *args, **kwargs):
        if request.user.is_anonymous and not settings.ANONYMOUS:
            return response_forbidden(request, article, kwargs.get('urlpath', None))
            return response_forbidden(request, article, kwargs.get("urlpath", None))
        if article.can_moderate(request.user):
            self.attachment = get_object_or_404(
                models.Attachment,
                id=attachment_id,
                articles=article)
                models.Attachment, id=attachment_id, articles=article
            )
            self.can_moderate = True
        else:
            self.attachment = get_object_or_404(
                models.Attachment.objects.active(),
                id=attachment_id,
                articles=article)
                models.Attachment.objects.active(), id=attachment_id, articles=article
            )
            self.can_moderate = False
        return super().dispatch(request, article, *args, **kwargs)



@@ 148,24 153,27 @@ class AttachmentReplaceView(ArticleMixin, FormView):
            self.attachment.save()
            messages.success(
                self.request,
                _('%s uploaded and replaces old attachment.') %
                attachment_revision.get_filename())
                _("%s uploaded and replaces old attachment.")
                % attachment_revision.get_filename(),
            )
            self.article.clear_cache()
        except models.IllegalFileExtension as e:
            messages.error(self.request, _('Your file could not be saved: %s') % e)
            messages.error(self.request, _("Your file could not be saved: %s") % e)
            return redirect(
                "wiki:attachments_replace",
                attachment_id=self.attachment.id,
                path=self.urlpath.path,
                article_id=self.article.id)
                article_id=self.article.id,
            )

        if self.can_moderate:
            if form.cleaned_data['replace']:
            if form.cleaned_data["replace"]:
                # form has no cleaned_data field unless self.can_moderate is True
                try:
                    most_recent_revision = self.attachment.attachmentrevision_set.exclude(
                        id=attachment_revision.id,
                        created__lte=attachment_revision.created).latest()
                        created__lte=attachment_revision.created,
                    ).latest()
                    most_recent_revision.delete()
                except ObjectDoesNotExist:
                    msg = "{attachment} does not contain any revisions.".format(


@@ 174,54 182,50 @@ class AttachmentReplaceView(ArticleMixin, FormView):
                    messages.error(self.request, msg)

        return redirect(
            "wiki:attachments_index",
            path=self.urlpath.path,
            article_id=self.article.id)
            "wiki:attachments_index", path=self.urlpath.path, article_id=self.article.id
        )

    def get_form(self, form_class=None):
        form = super().get_form(form_class=form_class)
        form.fields['file'].help_text = _(
            'Your new file will automatically be renamed to match the file already present. Files with different extensions are not allowed.')
        form.fields["file"].help_text = _(
            "Your new file will automatically be renamed to match the file already present. Files with different extensions are not allowed."
        )
        return form

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['article'] = self.article
        kwargs['request'] = self.request
        kwargs['attachment'] = self.attachment
        kwargs["article"] = self.article
        kwargs["request"] = self.request
        kwargs["attachment"] = self.attachment
        return kwargs

    def get_initial(self, **kwargs):
        return {'description': self.attachment.current_revision.description}
        return {"description": self.attachment.current_revision.description}

    def get_context_data(self, **kwargs):
        if 'form' not in kwargs:
            kwargs['form'] = self.get_form()
        kwargs['attachment'] = self.attachment
        kwargs['selected_tab'] = 'attachments'
        if "form" not in kwargs:
            kwargs["form"] = self.get_form()
        kwargs["attachment"] = self.attachment
        kwargs["selected_tab"] = "attachments"
        return super().get_context_data(**kwargs)


class AttachmentDownloadView(ArticleMixin, View):

    @method_decorator(get_article(can_read=True))
    def dispatch(self, request, article, attachment_id, *args, **kwargs):
        if article.can_moderate(request.user):
            self.attachment = get_object_or_404(
                models.Attachment,
                id=attachment_id,
                articles=article)
                models.Attachment, id=attachment_id, articles=article
            )
        else:
            self.attachment = get_object_or_404(
                models.Attachment.objects.active(),
                id=attachment_id,
                articles=article)
        revision_id = kwargs.get('revision_id', None)
                models.Attachment.objects.active(), id=attachment_id, articles=article
            )
        revision_id = kwargs.get("revision_id", None)
        if revision_id:
            self.revision = get_object_or_404(
                models.AttachmentRevision,
                id=revision_id,
                attachment__articles=article)
                models.AttachmentRevision, id=revision_id, attachment__articles=article
            )
        else:
            self.revision = self.attachment.current_revision
        return super().dispatch(request, article, *args, **kwargs)


@@ 234,7 238,8 @@ class AttachmentDownloadView(ArticleMixin, View):
                        request,
                        self.revision.file.path,
                        self.revision.created,
                        self.attachment.original_filename)
                        self.attachment.original_filename,
                    )
                except OSError:
                    pass
            else:


@@ 251,18 256,15 @@ class AttachmentChangeRevisionView(ArticleMixin, View):
    def dispatch(self, request, article, attachment_id, revision_id, *args, **kwargs):
        if article.can_moderate(request.user):
            self.attachment = get_object_or_404(
                models.Attachment,
                id=attachment_id,
                articles=article)
                models.Attachment, id=attachment_id, articles=article
            )
        else:
            self.attachment = get_object_or_404(
                models.Attachment.objects.active(),
                id=attachment_id,
                articles=article)
                models.Attachment.objects.active(), id=attachment_id, articles=article
            )
        self.revision = get_object_or_404(
            models.AttachmentRevision,
            id=revision_id,
            attachment__articles=article)
            models.AttachmentRevision, id=revision_id, attachment__articles=article
        )
        return super().dispatch(request, article, *args, **kwargs)

    def post(self, request, *args, **kwargs):


@@ 271,29 273,26 @@ class AttachmentChangeRevisionView(ArticleMixin, View):
        self.article.clear_cache()
        messages.success(
            self.request,
            _('Current revision changed for %s.') %
            self.attachment.original_filename)
            _("Current revision changed for %s.") % self.attachment.original_filename,
        )

        return redirect(
            "wiki:attachments_index",
            path=self.urlpath.path,
            article_id=self.article.id)
            "wiki:attachments_index", path=self.urlpath.path, article_id=self.article.id
        )

    def get_context_data(self, **kwargs):
        kwargs['selected_tab'] = 'attachments'
        if 'form' not in kwargs:
            kwargs['form'] = self.get_form()
        kwargs["selected_tab"] = "attachments"
        if "form" not in kwargs:
            kwargs["form"] = self.get_form()
        return ArticleMixin.get_context_data(self, **kwargs)


class AttachmentAddView(ArticleMixin, View):

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, attachment_id, *args, **kwargs):
        self.attachment = get_object_or_404(
            models.Attachment.objects.active().can_write(
                request.user),
            id=attachment_id)
            models.Attachment.objects.active().can_write(request.user), id=attachment_id
        )
        return super().dispatch(request, article, *args, **kwargs)

    def post(self, request, *args, **kwargs):


@@ 303,17 302,21 @@ class AttachmentAddView(ArticleMixin, View):
            self.article.clear_cache()
            messages.success(
                self.request,
                _('Added a reference to "%(att)s" from "%(art)s".') % {
                    'att': self.attachment.original_filename,
                    'art': self.article.current_revision.title})
                _('Added a reference to "%(att)s" from "%(art)s".')
                % {
                    "att": self.attachment.original_filename,
                    "art": self.article.current_revision.title,
                },
            )
        else:
            messages.error(
                self.request, _('"%(att)s" is already referenced.') %
                {'att': self.attachment.original_filename})
                self.request,
                _('"%(att)s" is already referenced.')
                % {"att": self.attachment.original_filename},
            )
        return redirect(
            "wiki:attachments_index",
            path=self.urlpath.path,
            article_id=self.article.id)
            "wiki:attachments_index", path=self.urlpath.path, article_id=self.article.id
        )


class AttachmentDeleteView(ArticleMixin, FormView):


@@ 323,9 326,11 @@ class AttachmentDeleteView(ArticleMixin, FormView):

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, attachment_id, *args, **kwargs):
        self.attachment = get_object_or_404(models.Attachment, id=attachment_id, articles=article)
        self.attachment = get_object_or_404(
            models.Attachment, id=attachment_id, articles=article
        )
        if not self.attachment.can_delete(request.user):
            return response_forbidden(request, article, kwargs.get('urlpath', None))
            return response_forbidden(request, article, kwargs.get("urlpath", None))
        return super().dispatch(request, article, *args, **kwargs)

    def form_valid(self, form):


@@ 335,27 340,39 @@ class AttachmentDeleteView(ArticleMixin, FormView):
            revision.attachment = self.attachment
            revision.set_from_request(self.request)
            revision.deleted = True
            revision.file = self.attachment.current_revision.file if self.attachment.current_revision else None
            revision.description = self.attachment.current_revision.description if self.attachment.current_revision else ""
            revision.file = (
                self.attachment.current_revision.file
                if self.attachment.current_revision
                else None
            )
            revision.description = (
                self.attachment.current_revision.description
                if self.attachment.current_revision
                else ""
            )
            revision.save()
            self.attachment.current_revision = revision
            self.attachment.save()
            self.article.clear_cache()
            messages.info(self.request, _('The file %s was deleted.') % self.attachment.original_filename)
            messages.info(
                self.request,
                _("The file %s was deleted.") % self.attachment.original_filename,
            )
        else:
            self.attachment.articles.remove(self.article)
            messages.info(
                self.request,
                _('This article is no longer related to the file %s.') %
                self.attachment.original_filename)
                _("This article is no longer related to the file %s.")
                % self.attachment.original_filename,
            )
        self.article.clear_cache()
        return redirect("wiki:get", path=self.urlpath.path, article_id=self.article.id)

    def get_context_data(self, **kwargs):
        kwargs['attachment'] = self.attachment
        kwargs['selected_tab'] = 'attachments'
        if 'form' not in kwargs:
            kwargs['form'] = self.get_form()
        kwargs["attachment"] = self.attachment
        kwargs["selected_tab"] = "attachments"
        if "form" not in kwargs:
            kwargs["form"] = self.get_form()
        return super().get_context_data(**kwargs)




@@ 363,7 380,7 @@ class AttachmentSearchView(ArticleMixin, ListView):

    template_name = "wiki/plugins/attachments/search.html"
    allow_empty = True
    context_object_name = 'attachments'
    context_object_name = "attachments"
    paginator_class = WikiPaginator
    paginate_by = 10



@@ 372,24 389,25 @@ class AttachmentSearchView(ArticleMixin, ListView):
        return super().dispatch(request, article, *args, **kwargs)

    def get_queryset(self):
        self.query = self.request.GET.get('query', None)
        self.query = self.request.GET.get("query", None)
        if not self.query:
            qs = models.Attachment.objects.none()
        else:
            qs = models.Attachment.objects.active().can_read(self.request.user)
            qs = qs.filter(
                Q(original_filename__contains=self.query) |
                Q(current_revision__description__contains=self.query) |
                Q(article__current_revision__title__contains=self.query))
        return qs.order_by('original_filename')
                Q(original_filename__contains=self.query)
                | Q(current_revision__description__contains=self.query)
                | Q(article__current_revision__title__contains=self.query)
            )
        return qs.order_by("original_filename")

    def get_context_data(self, **kwargs):
        # Is this a bit of a hack? Use better inheritance?
        kwargs_article = ArticleMixin.get_context_data(self, **kwargs)
        kwargs_listview = ListView.get_context_data(self, **kwargs)
        kwargs['search_form'] = forms.SearchForm(self.request.GET)
        kwargs['query'] = self.query
        kwargs["search_form"] = forms.SearchForm(self.request.GET)
        kwargs["query"] = self.query
        kwargs.update(kwargs_article)
        kwargs.update(kwargs_listview)
        kwargs['selected_tab'] = 'attachments'
        kwargs["selected_tab"] = "attachments"
        return kwargs

M src/wiki/plugins/attachments/wiki_plugin.py => src/wiki/plugins/attachments/wiki_plugin.py +15 -15
@@ 11,26 11,26 @@ from wiki.plugins.notifications.util import truncate_title
class AttachmentPlugin(BasePlugin):

    slug = settings.SLUG
    urlpatterns = {
        'article': [re_path('', include('wiki.plugins.attachments.urls'))]
    }
    urlpatterns = {"article": [re_path("", include("wiki.plugins.attachments.urls"))]}

    article_tab = (_('Attachments'), "fa fa-file")
    article_tab = (_("Attachments"), "fa fa-file")
    article_view = views.AttachmentView().dispatch

    # List of notifications to construct signal handlers for. This
    # is handled inside the notifications plugin.
    notifications = [{
        'model': models.AttachmentRevision,
        'message': lambda obj: (
            _("A file was changed: %s")
            if not obj.deleted
            else
            _("A file was deleted: %s")
        ) % truncate_title(obj.get_filename()),
        'key': ARTICLE_EDIT,
        'created': True,
        'get_article': lambda obj: obj.attachment.article}
    notifications = [
        {
            "model": models.AttachmentRevision,
            "message": lambda obj: (
                _("A file was changed: %s")
                if not obj.deleted
                else _("A file was deleted: %s")
            )
            % truncate_title(obj.get_filename()),
            "key": ARTICLE_EDIT,
            "created": True,
            "get_article": lambda obj: obj.attachment.article,
        }
    ]

    markdown_extensions = [AttachmentExtension()]

M src/wiki/plugins/editsection/__init__.py => src/wiki/plugins/editsection/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.editsection.apps.EditSectionConfig'
default_app_config = "wiki.plugins.editsection.apps.EditSectionConfig"

M src/wiki/plugins/editsection/apps.py => src/wiki/plugins/editsection/apps.py +2 -2
@@ 3,6 3,6 @@ from django.utils.translation import ugettext_lazy as _


class EditSectionConfig(AppConfig):
    name = 'wiki.plugins.editsection'
    name = "wiki.plugins.editsection"
    verbose_name = _("Wiki edit section")
    label = 'wiki_editsection'
    label = "wiki_editsection"

M src/wiki/plugins/editsection/markdown_extensions.py => src/wiki/plugins/editsection/markdown_extensions.py +27 -22
@@ 11,23 11,23 @@ from . import settings
class EditSectionExtension(Extension):
    def __init__(self, *args, **kwargs):
        self.config = {
            'level': [settings.MAX_LEVEL, 'Allow to edit sections until this level'],
            'headers': None,     # List of FindHeader, all headers with there positions
            'location': None,    # To be extracted header
            'header_id': None,   # Header text ID of the to be extracted header
            "level": [settings.MAX_LEVEL, "Allow to edit sections until this level"],
            "headers": None,  # List of FindHeader, all headers with there positions
            "location": None,  # To be extracted header
            "header_id": None,  # Header text ID of the to be extracted header
        }
        super().__init__(**kwargs)

    def extendMarkdown(self, md, md_globals):
        ext = EditSectionProcessor(md)
        ext.config = self.config
        md.treeprocessors.add('editsection', ext, '_end')
        md.treeprocessors.add("editsection", ext, "_end")


def get_header_id(header):
    header_id = ''.join(w[0] for w in re.findall(r"\w+", header))
    header_id = "".join(w[0] for w in re.findall(r"\w+", header))
    if not len(header_id):
        return '_'
        return "_"
    return header_id




@@ 48,8 48,11 @@ class EditSectionProcessor(Treeprocessor):

            # Find current position in headers
            cur_header += 1
            while (cur_header < len(self.headers) and
                   not self.headers[cur_header].sure_header and child.text != self.headers[cur_header].header):
            while (
                cur_header < len(self.headers)
                and not self.headers[cur_header].sure_header
                and child.text != self.headers[cur_header].header
            ):
                cur_header += 1
            if cur_header >= len(self.headers):
                return None


@@ 63,7 66,7 @@ class EditSectionProcessor(Treeprocessor):
            cur_pos[level - 1] += 1
            last_level = level

            location = '-'.join(map(str, cur_pos))
            location = "-".join(map(str, cur_pos))
            if location != self.location:
                continue



@@ 93,29 96,31 @@ class EditSectionProcessor(Treeprocessor):
                cur_pos[l] = 0
            cur_pos[level - 1] += 1
            last_level = level
            location = '-'.join(map(str, cur_pos))
            location = "-".join(map(str, cur_pos))
            header_id = get_header_id(child.text)

            # Insert link to allow editing this section
            link = etree.SubElement(child, 'a')
            link = etree.SubElement(child, "a")
            link.text = settings.LINK_TEXT
            link.attrib["class"] = "article-edit-title-link"

            # Build the URL
            url_kwargs = self.md.article.get_url_kwargs()
            url_kwargs['location'] = location
            url_kwargs['header'] = header_id
            link.attrib["href"] = reverse('wiki:editsection', kwargs=url_kwargs)
            url_kwargs["location"] = location
            url_kwargs["header"] = header_id
            link.attrib["href"] = reverse("wiki:editsection", kwargs=url_kwargs)

    def run(self, root):
        self.level = self.config.get('level')[0]
        self.HEADER_RE = re.compile('^h([' + ''.join(map(str, range(1, self.level + 1))) + '])')
        self.headers = self.config.get('headers')
        self.level = self.config.get("level")[0]
        self.HEADER_RE = re.compile(
            "^h([" + "".join(map(str, range(1, self.level + 1))) + "])"
        )
        self.headers = self.config.get("headers")
        if self.headers:
            self.location = self.config.get('location')
            self.header_id = self.config.get('header_id')
            self.config['location'] = self.locate_section(root)
            self.config['headers'] = None
            self.location = self.config.get("location")
            self.header_id = self.config.get("header_id")
            self.config["location"] = self.locate_section(root)
            self.config["headers"] = None
        else:
            self.add_links(root)
        return root

M src/wiki/plugins/editsection/settings.py => src/wiki/plugins/editsection/settings.py +3 -3
@@ 1,12 1,12 @@
from django.conf import settings as django_settings
from django.utils.translation import gettext

SLUG = 'editsection'
SLUG = "editsection"

#: Add "[edit]" links to all section headers till this level. By using
#: these links editing only the text from the selected section is possible.
MAX_LEVEL = getattr(django_settings, 'WIKI_EDITSECTION_MAX_LEVEL', 3)
MAX_LEVEL = getattr(django_settings, "WIKI_EDITSECTION_MAX_LEVEL", 3)

#: Text used for the section edit links which will appear next to section
#: headers. These links allow editing only the text of one particular section.
LINK_TEXT = getattr(django_settings, 'WIKI_EDITSECTION_LINK_TEXT', gettext("[edit]"))
LINK_TEXT = getattr(django_settings, "WIKI_EDITSECTION_LINK_TEXT", gettext("[edit]"))

M src/wiki/plugins/editsection/views.py => src/wiki/plugins/editsection/views.py +48 -41
@@ 17,8 17,7 @@ ERROR_SECTION_CHANGED = gettext_lazy(
    "Unable to find the selected section. The article was modified meanwhile."
)
ERROR_SECTION_UNSAVED = gettext_lazy(
    "Your changes must be re-applied in the new section structure of the "
    "article."
    "Your changes must be re-applied in the new section structure of the " "article."
)
ERROR_ARTICLE_CHANGED = gettext_lazy(
    "Unable to find the selected section in the current article. The article "


@@ 34,11 33,14 @@ class FindHeader:
    headers which are filtered out later in the markdown extension.
    Returns: start pos header sure_header level"""

    SETEXT_RE_TEXT = r'(?P<header1>.*?)\n(?P<level1>[=-])+[ ]*(\n|$)'
    SETEXT_RE = re.compile(r'\n%s' % SETEXT_RE_TEXT, re.MULTILINE)
    HEADER_RE = re.compile(r'((\A ?\n?|\n(?![^\n]{0,3}\w).*?\n)%s'
                           r'|(\A|\n)(?P<level2>#{1,6})(?P<header2>.*?)#*(\n|$))' % SETEXT_RE_TEXT, re.MULTILINE)
    ATTR_RE = re.compile(r'[ ]+\{\:?([^\}\n]*)\}[ ]*$')
    SETEXT_RE_TEXT = r"(?P<header1>.*?)\n(?P<level1>[=-])+[ ]*(\n|$)"
    SETEXT_RE = re.compile(r"\n%s" % SETEXT_RE_TEXT, re.MULTILINE)
    HEADER_RE = re.compile(
        r"((\A ?\n?|\n(?![^\n]{0,3}\w).*?\n)%s"
        r"|(\A|\n)(?P<level2>#{1,6})(?P<header2>.*?)#*(\n|$))" % SETEXT_RE_TEXT,
        re.MULTILINE,
    )
    ATTR_RE = re.compile(r"[ ]+\{\:?([^\}\n]*)\}[ ]*$")

    def __init__(self, text, pos):
        self.sure_header = False


@@ 54,23 56,23 @@ class FindHeader:
        self.pos = match.end() - 1

        # Get level and header text of the section
        token = match.group('level1')
        token = match.group("level1")
        if token:
            self.header = match.group('header1').strip()
            self.start = match.start('header1')
            self.header = match.group("header1").strip()
            self.start = match.start("header1")
        else:
            token = match.group('level2')
            self.header = match.group('header2').strip()
            self.start = match.start('level2')
            token = match.group("level2")
            self.header = match.group("header2").strip()
            self.start = match.start("level2")
            self.sure_header = True
        # Remove attribute definitions from the header text
        match = self.ATTR_RE.search(self.header)
        if match:
            self.header = self.header[:match.start()].rstrip('#').rstrip()
            self.header = self.header[: match.start()].rstrip("#").rstrip()
        # Get level of the section
        if token[0] == '=':
        if token[0] == "=":
            self.level = 1
        elif token[0] == '-':
        elif token[0] == "-":
            self.level = 2
        else:
            self.level = len(token)


@@ 99,43 101,42 @@ class EditSection(EditView):

        for e in get_markdown_extensions():
            if isinstance(e, EditSectionExtension):
                e.config['headers'] = headers
                e.config['location'] = self.location
                e.config['header_id'] = self.header_id
                e.config["headers"] = headers
                e.config["location"] = self.location
                e.config["header_id"] = self.header_id
                article_markdown(text, article)
                return e.config['location']
                return e.config["location"]
        return None

    def _redirect_to_article(self):
        if self.urlpath:
            return redirect('wiki:get', path=self.urlpath.path)
        return redirect('wiki:get', article_id=self.article.id)
            return redirect("wiki:get", path=self.urlpath.path)
        return redirect("wiki:get", article_id=self.article.id)

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.location = kwargs.pop('location', 0)
        self.header_id = kwargs.pop('header', 0)
        self.location = kwargs.pop("location", 0)
        self.header_id = kwargs.pop("header", 0)

        self.urlpath = kwargs.get('urlpath')
        kwargs['path'] = self.urlpath.path
        self.urlpath = kwargs.get("urlpath")
        kwargs["path"] = self.urlpath.path

        if request.method == 'GET':
        if request.method == "GET":
            text = article.current_revision.content
            location = self.locate_section(article, text)
            if location:
                self.orig_section = text[location[0]:location[1]]
                self.orig_section = text[location[0] : location[1]]
                # Pass the to be used content to EditSection
                kwargs['content'] = self.orig_section
                request.session['editsection_content'] = self.orig_section
                kwargs["content"] = self.orig_section
                request.session["editsection_content"] = self.orig_section
            else:
                messages.error(
                    request,
                    " ".format(ERROR_SECTION_CHANGED, ERROR_TRY_AGAIN)
                    request, " ".format(ERROR_SECTION_CHANGED, ERROR_TRY_AGAIN)
                )
                return self._redirect_to_article()
        else:
            kwargs['content'] = request.session.get('editsection_content')
            self.orig_section = kwargs.get('content')
            kwargs["content"] = request.session.get("editsection_content")
            self.orig_section = kwargs.get("content")

        return super().dispatch(request, article, *args, **kwargs)



@@ 148,25 149,31 @@ class EditSection(EditView):
        text = get_object_or_404(
            models.ArticleRevision,
            article=self.article,
            id=self.article.current_revision.previous_revision.id).content
            id=self.article.current_revision.previous_revision.id,
        ).content

        location = self.locate_section(self.article, text)
        if location:
            if self.orig_section != text[location[0]:location[1]]:
            if self.orig_section != text[location[0] : location[1]]:
                messages.warning(
                    self.request,
                    " ".format(ERROR_SECTION_CHANGED, ERROR_SECTION_UNSAVED, ERROR_TRY_AGAIN)
                    " ".format(
                        ERROR_SECTION_CHANGED, ERROR_SECTION_UNSAVED, ERROR_TRY_AGAIN
                    ),
                )
            # Include the edited section into the complete previous article
            self.article.current_revision.content = text[0:location[0]] + section + text[location[1]:]
            self.article.current_revision.content = (
                text[0 : location[0]] + section + text[location[1] :]
            )
            self.article.current_revision.save()
        else:
            # Back to the version before replacing the article with the section
            self.article.current_revision = self.article.current_revision.previous_revision
            self.article.current_revision = (
                self.article.current_revision.previous_revision
            )
            self.article.save()
            messages.error(
                self.request,
                " ".format(ERROR_ARTICLE_CHANGED, ERROR_TRY_AGAIN)
                self.request, " ".format(ERROR_ARTICLE_CHANGED, ERROR_TRY_AGAIN)
            )

        return self._redirect_to_article()

M src/wiki/plugins/editsection/wiki_plugin.py => src/wiki/plugins/editsection/wiki_plugin.py +9 -5
@@ 9,11 9,15 @@ from . import settings, views
class EditSectionPlugin(BasePlugin):

    slug = settings.SLUG
    urlpatterns = {'article': [
        url(r'^(?P<location>[0-9-]+)/header/(?P<header>\w+)/$',
            views.EditSection.as_view(),
            name='editsection'),
    ]}
    urlpatterns = {
        "article": [
            url(
                r"^(?P<location>[0-9-]+)/header/(?P<header>\w+)/$",
                views.EditSection.as_view(),
                name="editsection",
            ),
        ]
    }

    markdown_extensions = [EditSectionExtension()]


M src/wiki/plugins/globalhistory/__init__.py => src/wiki/plugins/globalhistory/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.globalhistory.apps.GlobalHistoryConfig'
default_app_config = "wiki.plugins.globalhistory.apps.GlobalHistoryConfig"

M src/wiki/plugins/globalhistory/apps.py => src/wiki/plugins/globalhistory/apps.py +2 -2
@@ 3,6 3,6 @@ from django.utils.translation import gettext_lazy as _


class GlobalHistoryConfig(AppConfig):
    name = 'wiki.plugins.globalhistory'
    name = "wiki.plugins.globalhistory"
    verbose_name = _("Wiki Global History")
    label = 'wiki_globalhistory'
    label = "wiki_globalhistory"

M src/wiki/plugins/globalhistory/settings.py => src/wiki/plugins/globalhistory/settings.py +1 -1
@@ 1,1 1,1 @@
SLUG = 'globalhistory'
SLUG = "globalhistory"

M src/wiki/plugins/globalhistory/views.py => src/wiki/plugins/globalhistory/views.py +12 -10
@@ 8,25 8,27 @@ from wiki.core.paginator import WikiPaginator

class GlobalHistory(ListView):

    template_name = 'wiki/plugins/globalhistory/globalhistory.html'
    template_name = "wiki/plugins/globalhistory/globalhistory.html"
    paginator_class = WikiPaginator
    paginate_by = 30
    model = models.ArticleRevision
    context_object_name = 'revisions'
    context_object_name = "revisions"

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        self.only_last = kwargs.get('only_last', 0)
        return super().dispatch(
            request, *args, **kwargs)
        self.only_last = kwargs.get("only_last", 0)
        return super().dispatch(request, *args, **kwargs)

    def get_queryset(self):
        if self.only_last == '1':
            return self.model.objects.can_read(self.request.user) \
                .filter(article__current_revision=F('id')).order_by('-modified')
        if self.only_last == "1":
            return (
                self.model.objects.can_read(self.request.user)
                .filter(article__current_revision=F("id"))
                .order_by("-modified")
            )
        else:
            return self.model.objects.can_read(self.request.user).order_by('-modified')
            return self.model.objects.can_read(self.request.user).order_by("-modified")

    def get_context_data(self, **kwargs):
        kwargs['only_last'] = self.only_last
        kwargs["only_last"] = self.only_last
        return super().get_context_data(**kwargs)

M src/wiki/plugins/globalhistory/wiki_plugin.py => src/wiki/plugins/globalhistory/wiki_plugin.py +10 -4
@@ 8,10 8,16 @@ from . import settings, views
class GlobalHistoryPlugin(BasePlugin):

    slug = settings.SLUG
    urlpatterns = {'root': [
        re_path(r'^$', views.GlobalHistory.as_view(), name='globalhistory'),
        re_path('^(?P<only_last>[01])/$', views.GlobalHistory.as_view(), name='globalhistory'),
    ]}
    urlpatterns = {
        "root": [
            re_path(r"^$", views.GlobalHistory.as_view(), name="globalhistory"),
            re_path(
                "^(?P<only_last>[01])/$",
                views.GlobalHistory.as_view(),
                name="globalhistory",
            ),
        ]
    }


registry.register(GlobalHistoryPlugin)

M src/wiki/plugins/help/__init__.py => src/wiki/plugins/help/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.help.apps.HelpConfig'
default_app_config = "wiki.plugins.help.apps.HelpConfig"

M src/wiki/plugins/help/apps.py => src/wiki/plugins/help/apps.py +2 -2
@@ 3,6 3,6 @@ from django.utils.translation import gettext_lazy as _


class HelpConfig(AppConfig):
    name = 'wiki.plugins.help'
    name = "wiki.plugins.help"
    verbose_name = _("Wiki help")
    label = 'wiki_help'
    label = "wiki_help"

M src/wiki/plugins/help/wiki_plugin.py => src/wiki/plugins/help/wiki_plugin.py +8 -6
@@ 5,13 5,15 @@ from wiki.core.plugins.base import BasePlugin

class HelpPlugin(BasePlugin):

    slug = 'help'
    slug = "help"

    sidebar = {'headline': _('Help'),
               'icon_class': 'fa-question-circle',
               'template': 'wiki/plugins/help/sidebar.html',
               'form_class': None,
               'get_form_kwargs': (lambda a: {})}
    sidebar = {
        "headline": _("Help"),
        "icon_class": "fa-question-circle",
        "template": "wiki/plugins/help/sidebar.html",
        "form_class": None,
        "get_form_kwargs": (lambda a: {}),
    }

    markdown_extensions = []


M src/wiki/plugins/images/__init__.py => src/wiki/plugins/images/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.images.apps.ImagesConfig'
default_app_config = "wiki.plugins.images.apps.ImagesConfig"

M src/wiki/plugins/images/admin.py => src/wiki/plugins/images/admin.py +6 -7
@@ 5,7 5,6 @@ from . import models


class ImageForm(forms.ModelForm):

    class Meta:
        model = models.Image
        exclude = ()


@@ 13,19 12,19 @@ class ImageForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance.pk:
            revisions = models.ImageRevision.objects.filter(
                plugin=self.instance)
            self.fields['current_revision'].queryset = revisions
            revisions = models.ImageRevision.objects.filter(plugin=self.instance)
            self.fields["current_revision"].queryset = revisions
        else:
            self.fields[
                'current_revision'].queryset = models.ImageRevision.objects.none()
            self.fields['current_revision'].widget = forms.HiddenInput()
                "current_revision"
            ].queryset = models.ImageRevision.objects.none()
            self.fields["current_revision"].widget = forms.HiddenInput()


class ImageRevisionInline(admin.TabularInline):
    model = models.ImageRevision
    extra = 1
    fields = ('image', 'locked', 'deleted')
    fields = ("image", "locked", "deleted")


class ImageAdmin(admin.ModelAdmin):

M src/wiki/plugins/images/apps.py => src/wiki/plugins/images/apps.py +6 -3
@@ 6,9 6,12 @@ from . import checks


class ImagesConfig(AppConfig):
    name = 'wiki.plugins.images'
    name = "wiki.plugins.images"
    verbose_name = _("Wiki images")
    label = 'wiki_images'
    label = "wiki_images"

    def ready(self):
        register(checks.check_for_required_installed_apps, checks.Tags.required_installed_apps)
        register(
            checks.check_for_required_installed_apps,
            checks.Tags.required_installed_apps,
        )

M src/wiki/plugins/images/checks.py => src/wiki/plugins/images/checks.py +3 -1
@@ 9,5 9,7 @@ class Tags:
def check_for_required_installed_apps(app_configs, **kwargs):
    errors = []
    if not apps.is_installed("sorl.thumbnail"):
        errors.append(Error('needs sorl.thumbnail in INSTALLED_APPS', id='wiki_images.E001'))
        errors.append(
            Error("needs sorl.thumbnail in INSTALLED_APPS", id="wiki_images.E001")
        )
    return errors

M src/wiki/plugins/images/forms.py => src/wiki/plugins/images/forms.py +17 -15
@@ 5,22 5,25 @@ from wiki.plugins.images import models


class SidebarForm(PluginSidebarFormMixin):

    def __init__(self, article, request, *args, **kwargs):
        self.article = article
        self.request = request
        super().__init__(*args, **kwargs)
        self.fields['image'].required = True
        self.fields["image"].required = True

    def get_usermessage(self):
        return gettext(
            "New image %s was successfully uploaded. You can use it by selecting it from the list of available images.") % self.instance.get_filename()
        return (
            gettext(
                "New image %s was successfully uploaded. You can use it by selecting it from the list of available images."
            )
            % self.instance.get_filename()
        )

    def save(self, *args, **kwargs):
        if not self.instance.id:
            image = models.Image()
            image.article = self.article
            kwargs['commit'] = False
            kwargs["commit"] = False
            revision = super().save(*args, **kwargs)
            revision.set_from_request(self.request)
            image.add_revision(self.instance, save=True)


@@ 29,20 32,19 @@ class SidebarForm(PluginSidebarFormMixin):

    class Meta:
        model = models.ImageRevision
        fields = ('image',)
        fields = ("image",)


class RevisionForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):
        self.image = kwargs.pop('image')
        self.request = kwargs.pop('request')
        self.image = kwargs.pop("image")
        self.request = kwargs.pop("request")
        super().__init__(*args, **kwargs)
        self.fields['image'].required = True
        self.fields["image"].required = True

    def save(self, *args, **kwargs):
        if not self.instance.id:
            kwargs['commit'] = False
            kwargs["commit"] = False
            revision = super().save(*args, **kwargs)
            revision.inherit_predecessor(self.image, skip_image_file=True)
            revision.deleted = False  # Restore automatically if deleted


@@ 53,15 55,15 @@ class RevisionForm(forms.ModelForm):

    class Meta:
        model = models.ImageRevision
        fields = ('image',)
        fields = ("image",)


class PurgeForm(forms.Form):

    confirm = forms.BooleanField(label=_('Are you sure?'), required=False)
    confirm = forms.BooleanField(label=_("Are you sure?"), required=False)

    def clean_confirm(self):
        confirm = self.cleaned_data['confirm']
        confirm = self.cleaned_data["confirm"]
        if not confirm:
            raise forms.ValidationError(gettext('You are not sure enough!'))
            raise forms.ValidationError(gettext("You are not sure enough!"))
        return confirm

M src/wiki/plugins/images/markdown_extensions.py => src/wiki/plugins/images/markdown_extensions.py +13 -9
@@ 3,17 3,22 @@ from django.template.loader import render_to_string
from wiki.plugins.images import models, settings

IMAGE_RE = (
    r"(?:(?im)" +
    r"(?:(?im)"
    +
    # Match '[image:N'
    r"\[image\:(?P<id>[0-9]+)" +
    r"\[image\:(?P<id>[0-9]+)"
    +
    # Match optional 'align'
    r"(?:\s+align\:(?P<align>right|left))?" +
    r"(?:\s+align\:(?P<align>right|left))?"
    +
    # Match optional 'size'
    r"(?:\s+size\:(?P<size>default|small|medium|large|orig))?" +
    r"(?:\s+size\:(?P<size>default|small|medium|large|orig))?"
    +
    # Match ']' and rest of line.
    # Normally [^\n] could be replaced with a dot '.', since '.'
    # does not match newlines, but inline processors run with re.DOTALL.
    r"\s*\](?P<trailer>[^\n]*)$" +
    r"\s*\](?P<trailer>[^\n]*)$"
    +
    # Match zero or more caption lines, each indented by four spaces.
    r"(?P<caption>(?:\n    [^\n]*)*))"
)


@@ 24,8 29,8 @@ class ImageExtension(markdown.Extension):
    """ Images plugin markdown extension for django-wiki. """

    def extendMarkdown(self, md):
        md.inlinePatterns.add('dw-images', ImagePattern(IMAGE_RE, md), '>link')
        md.postprocessors.add('dw-images-cleanup', ImagePostprocessor(md), '>raw_html')
        md.inlinePatterns.add("dw-images", ImagePattern(IMAGE_RE, md), ">link")
        md.postprocessors.add("dw-images-cleanup", ImagePostprocessor(md), ">raw_html")


class ImagePattern(markdown.inlinepatterns.Pattern):


@@ 61,7 66,7 @@ class ImagePattern(markdown.inlinepatterns.Pattern):
            pass

        caption = m.group("caption")
        trailer = m.group('trailer')
        trailer = m.group("trailer")

        caption_placeholder = "{{{IMAGECAPTION}}}"
        width = size.split("x")[0] if size else None


@@ 82,7 87,6 @@ class ImagePattern(markdown.inlinepatterns.Pattern):


class ImagePostprocessor(markdown.postprocessors.Postprocessor):

    def run(self, text):
        """
        This cleans up after Markdown's well-intended placing of image tags

M src/wiki/plugins/images/migrations/0001_initial.py => src/wiki/plugins/images/migrations/0001_initial.py +44 -17
@@ 5,34 5,61 @@ from django.db import migrations, models
class Migration(migrations.Migration):

    dependencies = [
        ('wiki', '0001_initial'),
        ("wiki", "0001_initial"),
    ]

    operations = [
        migrations.CreateModel(
            name='Image',
            name="Image",
            fields=[
                ('revisionplugin_ptr', models.OneToOneField(to='wiki.RevisionPlugin', primary_key=True, auto_created=True, parent_link=True, serialize=False, on_delete=models.CASCADE)),
                (
                    "revisionplugin_ptr",
                    models.OneToOneField(
                        to="wiki.RevisionPlugin",
                        primary_key=True,
                        auto_created=True,
                        parent_link=True,
                        serialize=False,
                        on_delete=models.CASCADE,
                    ),
                ),
            ],
            options={
                'verbose_name': 'image',
                'verbose_name_plural': 'images',
            },
            bases=('wiki.revisionplugin',),
            options={"verbose_name": "image", "verbose_name_plural": "images",},
            bases=("wiki.revisionplugin",),
        ),
        migrations.CreateModel(
            name='ImageRevision',
            name="ImageRevision",
            fields=[
                ('revisionpluginrevision_ptr', models.OneToOneField(to='wiki.RevisionPluginRevision', primary_key=True, auto_created=True, parent_link=True, serialize=False, on_delete=models.CASCADE)),
                ('image', models.ImageField(null=True, blank=True, height_field='height', max_length=2000, width_field='width', upload_to=wiki.plugins.images.models.upload_path)),
                ('width', models.SmallIntegerField(null=True, blank=True)),
                ('height', models.SmallIntegerField(null=True, blank=True)),
                (
                    "revisionpluginrevision_ptr",
                    models.OneToOneField(
                        to="wiki.RevisionPluginRevision",
                        primary_key=True,
                        auto_created=True,
                        parent_link=True,
                        serialize=False,
                        on_delete=models.CASCADE,
                    ),
                ),
                (
                    "image",
                    models.ImageField(
                        null=True,
                        blank=True,
                        height_field="height",
                        max_length=2000,
                        width_field="width",
                        upload_to=wiki.plugins.images.models.upload_path,
                    ),
                ),
                ("width", models.SmallIntegerField(null=True, blank=True)),
                ("height", models.SmallIntegerField(null=True, blank=True)),
            ],
            options={
                'verbose_name': 'image revision',
                'verbose_name_plural': 'image revisions',
                'ordering': ('-created',),
                "verbose_name": "image revision",
                "verbose_name_plural": "image revisions",
                "ordering": ("-created",),
            },
            bases=('wiki.revisionpluginrevision',),
            bases=("wiki.revisionpluginrevision",),
        ),
    ]

M src/wiki/plugins/images/migrations/0002_auto_20151118_1811.py => src/wiki/plugins/images/migrations/0002_auto_20151118_1811.py +3 -7
@@ 6,16 6,12 @@ class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('wiki_images', '0001_initial'),
        ("wiki_images", "0001_initial"),
    ]

    operations = [
        migrations.AlterModelTable(name="image", table="wiki_images_image",),
        migrations.AlterModelTable(
            name='image',
            table='wiki_images_image',
        ),
        migrations.AlterModelTable(
            name='imagerevision',
            table='wiki_images_imagerevision',
            name="imagerevision", table="wiki_images_imagerevision",
        ),
    ]

M src/wiki/plugins/images/models.py => src/wiki/plugins/images/models.py +28 -21
@@ 13,10 13,10 @@ def upload_path(instance, filename):
    # Has to match original extension filename

    upload_path = settings.IMAGE_PATH
    upload_path = upload_path.replace(
        '%aid', str(instance.plugin.image.article.id))
    upload_path = upload_path.replace("%aid", str(instance.plugin.image.article.id))
    if settings.IMAGE_PATH_OBSCURIFY:
        import uuid

        upload_path = os.path.join(upload_path, uuid.uuid4().hex)
    return os.path.join(upload_path, filename)



@@ 35,23 35,31 @@ class Image(RevisionPlugin):
        return self.can_write(user)

    class Meta:
        verbose_name = _('image')
        verbose_name_plural = _('images')
        db_table = 'wiki_images_image'  # Matches label of upcoming 0.1 release
        verbose_name = _("image")
        verbose_name_plural = _("images")
        db_table = "wiki_images_image"  # Matches label of upcoming 0.1 release

    def __str__(self):
        if self.current_revision:
            return gettext('Image: %s') % self.current_revision.imagerevision.get_filename()
            return (
                gettext("Image: %s")
                % self.current_revision.imagerevision.get_filename()
            )
        else:
            return gettext('Current revision not set!!')
            return gettext("Current revision not set!!")


class ImageRevision(RevisionPluginRevision):

    image = models.ImageField(upload_to=upload_path,
                              max_length=2000, height_field='height',
                              width_field='width', blank=True, null=True,
                              storage=settings.STORAGE_BACKEND)
    image = models.ImageField(
        upload_to=upload_path,
        max_length=2000,
        height_field="height",
        width_field="width",
        blank=True,
        null=True,
        storage=settings.STORAGE_BACKEND,
    )

    width = models.SmallIntegerField(blank=True, null=True)
    height = models.SmallIntegerField(blank=True, null=True)


@@ 59,7 67,7 @@ class ImageRevision(RevisionPluginRevision):
    def get_filename(self):
        if self.image:
            try:
                return self.image.name.split('/')[-1]
                return self.image.name.split("/")[-1]
            except OSError:
                pass
        return None


@@ 94,17 102,17 @@ class ImageRevision(RevisionPluginRevision):
                self.image = None

    class Meta:
        verbose_name = _('image revision')
        verbose_name_plural = _('image revisions')
        verbose_name = _("image revision")
        verbose_name_plural = _("image revisions")
        # Matches label of upcoming 0.1 release
        db_table = 'wiki_images_imagerevision'
        ordering = ('-created',)
        db_table = "wiki_images_imagerevision"
        ordering = ("-created",)

    def __str__(self):
        if self.revision_number:
            return gettext('Image Revision: %d') % self.revision_number
            return gettext("Image Revision: %d") % self.revision_number
        else:
            return gettext('Current revision not set!!')
            return gettext("Current revision not set!!")


def on_image_revision_delete(instance, *args, **kwargs):


@@ 117,7 125,7 @@ def on_image_revision_delete(instance, *args, **kwargs):
    try:
        path = instance.image.path.split("/")[:-1]
    except NotImplementedError:
            # This backend storage doesn't implement 'path' so there is no path to delete
        # This backend storage doesn't implement 'path' so there is no path to delete
        return

    # Clean up empty directories


@@ 131,8 139,7 @@ def on_image_revision_delete(instance, *args, **kwargs):
    for depth in range(0, max_depth):
        delete_path = "/".join(path[:-depth] if depth > 0 else path)
        try:
            dir_list = os.listdir(
                os.path.join(django_settings.MEDIA_ROOT, delete_path))
            dir_list = os.listdir(os.path.join(django_settings.MEDIA_ROOT, delete_path))
        except OSError:
            # Path does not exist, so let's not try to remove it...
            dir_list = None

M src/wiki/plugins/images/settings.py => src/wiki/plugins/images/settings.py +18 -19
@@ 1,46 1,45 @@
from django.conf import settings as django_settings
from wiki.conf import settings as wiki_settings

SLUG = 'images'
SLUG = "images"

# Deprecated
APP_LABEL = None

#: Location where uploaded images are stored. ``%aid`` is replaced by the article id.
IMAGE_PATH = getattr(django_settings, 'WIKI_IMAGES_PATH', "wiki/images/%aid/")
IMAGE_PATH = getattr(django_settings, "WIKI_IMAGES_PATH", "wiki/images/%aid/")

#: Size for the image thumbnail included in the HTML text. If no specific
#: size is given in the markdown tag the ``default`` size is used. If a
#: specific size is given in the markdown tag that size is used.
THUMBNAIL_SIZES = getattr(django_settings, 'WIKI_IMAGES_THUMBNAIL_SIZES', {
    'default': '250x250',
    'small': '150x150',
    'medium': '300x300',
    'large': '500x500',
    'orig': None
})
THUMBNAIL_SIZES = getattr(
    django_settings,
    "WIKI_IMAGES_THUMBNAIL_SIZES",
    {
        "default": "250x250",
        "small": "150x150",
        "medium": "300x300",
        "large": "500x500",
        "orig": None,
    },
)

#: Storage backend to use, default is to use the same as the rest of the
#: wiki, which is set in ``WIKI_STORAGE_BACKEND``, but you can override it
#: with ``WIKI_IMAGES_STORAGE_BACKEND``.
STORAGE_BACKEND = getattr(
    django_settings,
    'WIKI_IMAGES_STORAGE_BACKEND',
    wiki_settings.STORAGE_BACKEND)
    django_settings, "WIKI_IMAGES_STORAGE_BACKEND", wiki_settings.STORAGE_BACKEND
)

#: Should the upload path be obscurified? If so, a random hash will be added
#: to the path such that someone can not guess the location of files (if you
#: have restricted permissions and the files are still located within the
#: web server's file system).
IMAGE_PATH_OBSCURIFY = getattr(
    django_settings,
    'WIKI_IMAGES_PATH_OBSCURIFY',
    True)
IMAGE_PATH_OBSCURIFY = getattr(django_settings, "WIKI_IMAGES_PATH_OBSCURIFY", True)

#: Allow anonymous users upload access (not nice on an open network).
#: ``WIKI_IMAGES_ANONYMOUS`` can override this, otherwise the default
#: in ``wiki.conf.settings`` is used.
ANONYMOUS = getattr(
    django_settings,
    'WIKI_IMAGES_ANONYMOUS',
    wiki_settings.ANONYMOUS_UPLOAD)
    django_settings, "WIKI_IMAGES_ANONYMOUS", wiki_settings.ANONYMOUS_UPLOAD
)

M src/wiki/plugins/images/templatetags/wiki_images_tags.py => src/wiki/plugins/images/templatetags/wiki_images_tags.py +2 -2
@@ 7,8 7,8 @@ register = template.Library()
@register.filter
def images_for_article(article):
    return models.Image.objects.filter(
        article=article, current_revision__deleted=False).order_by(
        '-current_revision__created')
        article=article, current_revision__deleted=False
    ).order_by("-current_revision__created")


@register.filter

M src/wiki/plugins/images/views.py => src/wiki/plugins/images/views.py +64 -52
@@ 18,9 18,9 @@ logger = logging.getLogger(__name__)

class ImageView(ArticleMixin, ListView):

    template_name = 'wiki/plugins/images/index.html'
    template_name = "wiki/plugins/images/index.html"
    allow_empty = True
    context_object_name = 'images'
    context_object_name = "images"
    paginator_class = WikiPaginator
    paginate_by = 10



@@ 29,15 29,16 @@ class ImageView(ArticleMixin, ListView):
        return super().dispatch(request, article, *args, **kwargs)

    def get_queryset(self):
        if (self.article.can_moderate(self.request.user) or
                self.article.can_delete(self.request.user)):
        if self.article.can_moderate(self.request.user) or self.article.can_delete(
            self.request.user
        ):
            images = models.Image.objects.filter(article=self.article)
        else:
            images = models.Image.objects.filter(
                article=self.article,
                current_revision__deleted=False)
                article=self.article, current_revision__deleted=False
            )
        images.select_related()
        return images.order_by('-current_revision__imagerevision__created')
        return images.order_by("-current_revision__imagerevision__created")

    def get_context_data(self, **kwargs):
        kwargs.update(ArticleMixin.get_context_data(self, **kwargs))


@@ 50,35 51,47 @@ class DeleteView(ArticleMixin, RedirectView):

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.image = get_object_or_404(models.Image, article=article,
                                       id=kwargs.get('image_id', None))
        self.restore = kwargs.get('restore', False)
        self.image = get_object_or_404(
            models.Image, article=article, id=kwargs.get("image_id", None)
        )
        self.restore = kwargs.get("restore", False)
        return ArticleMixin.dispatch(self, request, article, *args, **kwargs)

    def get_redirect_url(self, **kwargs):

        if not self.image.current_revision:
            logger.critical('Encountered an image without current revision set, ID: {}'.format(self.image.id))
            logger.critical(
                "Encountered an image without current revision set, ID: {}".format(
                    self.image.id
                )
            )
            latest_revision = RevisionPluginRevision.objects.filter(
                plugin=self.image
            ).latest('pk')
            ).latest("pk")
            self.image.current_revision = latest_revision

        new_revision = models.ImageRevision()
        new_revision.inherit_predecessor(self.image)
        new_revision.set_from_request(self.request)
        new_revision.revision_number = RevisionPluginRevision.objects.filter(plugin=self.image).count()
        new_revision.revision_number = RevisionPluginRevision.objects.filter(
            plugin=self.image
        ).count()
        new_revision.deleted = not self.restore
        new_revision.save()
        self.image.current_revision = new_revision
        self.image.save()
        if self.restore:
            messages.info(self.request, _('%s has been restored') % new_revision.get_filename())
            messages.info(
                self.request, _("%s has been restored") % new_revision.get_filename()
            )
        else:
            messages.info(self.request, _('%s has been marked as deleted') % new_revision.get_filename())
            messages.info(
                self.request,
                _("%s has been marked as deleted") % new_revision.get_filename(),
            )
        if self.urlpath:
            return reverse('wiki:images_index', kwargs={'path': self.urlpath.path})
        return reverse('wiki:images_index', kwargs={'article_id': self.article.id})
            return reverse("wiki:images_index", kwargs={"path": self.urlpath.path})
        return reverse("wiki:images_index", kwargs={"article_id": self.article.id})


class PurgeView(ArticleMixin, FormView):


@@ 89,8 102,9 @@ class PurgeView(ArticleMixin, FormView):

    @method_decorator(get_article(can_write=True, can_moderate=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.image = get_object_or_404(models.Image, article=article,
                                       id=kwargs.get('image_id', None))
        self.image = get_object_or_404(
            models.Image, article=article, id=kwargs.get("image_id", None)
        )
        return super().dispatch(request, article, *args, **kwargs)

    def form_valid(self, form):


@@ 100,14 114,14 @@ class PurgeView(ArticleMixin, FormView):
            revision.imagerevision.delete()

        if self.urlpath:
            return redirect('wiki:images_index', path=self.urlpath.path)
        return redirect('wiki:images_index', article_id=self.article_id)
            return redirect("wiki:images_index", path=self.urlpath.path)
        return redirect("wiki:images_index", article_id=self.article_id)

    def get_context_data(self, **kwargs):
        # Needed since Django 1.9 because get_context_data is no longer called
        # with the form instance
        if 'form' not in kwargs:
            kwargs['form'] = self.get_form()
        if "form" not in kwargs:
            kwargs["form"] = self.get_form()
        kwargs = ArticleMixin.get_context_data(self, **kwargs)
        kwargs.update(FormView.get_context_data(self, **kwargs))
        return kwargs


@@ 119,14 133,12 @@ class RevisionChangeView(ArticleMixin, RedirectView):

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.image = get_object_or_404(models.Image, article=article,
                                       id=kwargs.get('image_id', None))
        self.image = get_object_or_404(
            models.Image, article=article, id=kwargs.get("image_id", None)
        )
        self.revision = get_object_or_404(
            models.ImageRevision,
            plugin__article=article,
            id=kwargs.get(
                'rev_id',
                None))
            models.ImageRevision, plugin__article=article, id=kwargs.get("rev_id", None)
        )
        return ArticleMixin.dispatch(self, request, article, *args, **kwargs)

    def get_redirect_url(self, **kwargs):


@@ 135,18 147,15 @@ class RevisionChangeView(ArticleMixin, RedirectView):
        self.image.save()
        messages.info(
            self.request,
            _('%(file)s has been changed to revision #%(revision)d') % {
                'file': self.image.current_revision.imagerevision.get_filename(),
                'revision': self.revision.revision_number})
            _("%(file)s has been changed to revision #%(revision)d")
            % {
                "file": self.image.current_revision.imagerevision.get_filename(),
                "revision": self.revision.revision_number,
            },
        )
        if self.urlpath:
            return reverse(
                'wiki:images_index',
                kwargs={
                    'path': self.urlpath.path})
        return reverse(
            'wiki:images_index',
            kwargs={
                'article_id': self.article.id})
            return reverse("wiki:images_index", kwargs={"path": self.urlpath.path})
        return reverse("wiki:images_index", kwargs={"article_id": self.article.id})


class RevisionAddView(ArticleMixin, FormView):


@@ 156,32 165,35 @@ class RevisionAddView(ArticleMixin, FormView):

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.image = get_object_or_404(models.Image, article=article,
                                       id=kwargs.get('image_id', None))
        self.image = get_object_or_404(
            models.Image, article=article, id=kwargs.get("image_id", None)
        )
        if not self.image.can_write(request.user):
            return redirect(wiki_settings.LOGIN_URL)
        return ArticleMixin.dispatch(self, request, article, *args, **kwargs)

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs(**kwargs)
        kwargs['image'] = self.image
        kwargs['request'] = self.request
        kwargs["image"] = self.image
        kwargs["request"] = self.request
        return kwargs

    def get_context_data(self, **kwargs):
        # Needed since Django 1.9 because get_context_data is no longer called
        # with the form instance
        if 'form' not in kwargs:
            kwargs['form'] = self.get_form()
        if "form" not in kwargs:
            kwargs["form"] = self.get_form()
        kwargs = super().get_context_data(**kwargs)
        kwargs['image'] = self.image
        kwargs["image"] = self.image
        return kwargs

    def form_valid(self, form, **kwargs):
        form.save()
        messages.info(
            self.request, _('%(file)s has been saved.') %
            {'file': self.image.current_revision.imagerevision.get_filename(), })
            self.request,
            _("%(file)s has been saved.")
            % {"file": self.image.current_revision.imagerevision.get_filename(),},
        )
        if self.urlpath:
            return redirect('wiki:edit', path=self.urlpath.path)
        return redirect('wiki:edit', article_id=self.article.id)
            return redirect("wiki:edit", path=self.urlpath.path)
        return redirect("wiki:edit", article_id=self.article.id)

M src/wiki/plugins/images/wiki_plugin.py => src/wiki/plugins/images/wiki_plugin.py +49 -38
@@ 12,56 12,67 @@ class ImagePlugin(BasePlugin):

    slug = settings.SLUG
    sidebar = {
        'headline': _('Images'),
        'icon_class': 'fa-picture-o',
        'template': 'wiki/plugins/images/sidebar.html',
        'form_class': forms.SidebarForm,
        'get_form_kwargs': (lambda a: {'instance': models.Image(article=a)})
        "headline": _("Images"),
        "icon_class": "fa-picture-o",
        "template": "wiki/plugins/images/sidebar.html",
        "form_class": forms.SidebarForm,
        "get_form_kwargs": (lambda a: {"instance": models.Image(article=a)}),
    }

    # List of notifications to construct signal handlers for. This
    # is handled inside the notifications plugin.
    notifications = [
        {'model': models.ImageRevision,
         'message': lambda obj: _("An image was added: %s") % truncate_title(obj.get_filename()),
         'key': ARTICLE_EDIT,
         'created': False,
         # Ignore if there is a previous revision... the image isn't new
         'ignore': lambda revision: bool(revision.previous_revision),
         'get_article': lambda obj: obj.article}
        {
            "model": models.ImageRevision,
            "message": lambda obj: _("An image was added: %s")
            % truncate_title(obj.get_filename()),
            "key": ARTICLE_EDIT,
            "created": False,
            # Ignore if there is a previous revision... the image isn't new
            "ignore": lambda revision: bool(revision.previous_revision),
            "get_article": lambda obj: obj.article,
        }
    ]

    class RenderMedia:
        js = [
            'wiki/colorbox/jquery.colorbox-min.js',
            'wiki/js/images.js',
            "wiki/colorbox/jquery.colorbox-min.js",
            "wiki/js/images.js",
        ]

        css = {
            'screen': 'wiki/colorbox/example1/colorbox.css'
        }
        css = {"screen": "wiki/colorbox/example1/colorbox.css"}

    urlpatterns = {'article': [
        re_path('^$',
            views.ImageView.as_view(),
            name='images_index'),
        re_path('^delete/(?P<image_id>[0-9]+)/$',
            views.DeleteView.as_view(),
            name='images_delete'),
        re_path('^restore/(?P<image_id>[0-9]+)/$',
            views.DeleteView.as_view(),
            name='images_restore',
            kwargs={'restore': True}),
        re_path('^purge/(?P<image_id>[0-9]+)/$',
            views.PurgeView.as_view(),
            name='images_purge'),
        re_path('^(?P<image_id>[0-9]+)/revision/change/(?P<rev_id>[0-9]+)/$',
            views.RevisionChangeView.as_view(),
            name='images_set_revision'),
        re_path('^(?P<image_id>[0-9]+)/revision/add/$',
            views.RevisionAddView.as_view(),
            name='images_add_revision'),
    ]}
    urlpatterns = {
        "article": [
            re_path("^$", views.ImageView.as_view(), name="images_index"),
            re_path(
                "^delete/(?P<image_id>[0-9]+)/$",
                views.DeleteView.as_view(),
                name="images_delete",
            ),
            re_path(
                "^restore/(?P<image_id>[0-9]+)/$",
                views.DeleteView.as_view(),
                name="images_restore",
                kwargs={"restore": True},
            ),
            re_path(
                "^purge/(?P<image_id>[0-9]+)/$",
                views.PurgeView.as_view(),
                name="images_purge",
            ),
            re_path(
                "^(?P<image_id>[0-9]+)/revision/change/(?P<rev_id>[0-9]+)/$",
                views.RevisionChangeView.as_view(),
                name="images_set_revision",
            ),
            re_path(
                "^(?P<image_id>[0-9]+)/revision/add/$",
                views.RevisionAddView.as_view(),
                name="images_add_revision",
            ),
        ]
    }

    markdown_extensions = [ImageExtension()]


M src/wiki/plugins/links/__init__.py => src/wiki/plugins/links/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.links.apps.LinksConfig'
default_app_config = "wiki.plugins.links.apps.LinksConfig"

M src/wiki/plugins/links/apps.py => src/wiki/plugins/links/apps.py +2 -2
@@ 3,6 3,6 @@ from django.utils.translation import gettext_lazy as _


class LinksConfig(AppConfig):
    name = 'wiki.plugins.links'
    name = "wiki.plugins.links"
    verbose_name = _("Wiki links")
    label = 'wiki_links'
    label = "wiki_links"

M src/wiki/plugins/links/mdx/djangowikilinks.py => src/wiki/plugins/links/mdx/djangowikilinks.py +27 -32
@@ 24,19 24,16 @@ from wiki import models


class WikiPathExtension(markdown.extensions.Extension):

    def __init__(self, configs):
        # set extension defaults
        self.config = {
            'base_url': [
                '/',
                'String to append to beginning of URL.'],
            'html_class': [
                'wikipath',
                'CSS hook. Leave blank for none.'],
            'default_level': [
            "base_url": ["/", "String to append to beginning of URL."],
            "html_class": ["wikipath", "CSS hook. Leave blank for none."],
            "default_level": [
                2,
                'The level that most articles are created at. Relative links will tend to start at that level.']}
                "The level that most articles are created at. Relative links will tend to start at that level.",
            ],
        }

        # Override defaults with user settings
        for key, value in configs:


@@ 46,20 43,19 @@ class WikiPathExtension(markdown.extensions.Extension):
        self.md = md

        # append to end of inline patterns
        WIKI_RE = r'\[(?P<label>[^\]]+?)\]\(wiki:(?P<wikipath>[a-zA-Z0-9\./_-]*?)(?P<fragment>#[a-zA-Z0-9\./_-]*)?\)'
        WIKI_RE = r"\[(?P<label>[^\]]+?)\]\(wiki:(?P<wikipath>[a-zA-Z0-9\./_-]*?)(?P<fragment>#[a-zA-Z0-9\./_-]*)?\)"
        wikiPathPattern = WikiPath(WIKI_RE, self.config, md=md)
        wikiPathPattern.md = md
        md.inlinePatterns.add('djangowikipath', wikiPathPattern, "<reference")
        md.inlinePatterns.add("djangowikipath", wikiPathPattern, "<reference")


class WikiPath(markdown.inlinepatterns.Pattern):

    def __init__(self, pattern, config, **kwargs):
        super().__init__(pattern, **kwargs)
        self.config = config

    def handleMatch(self, m):
        wiki_path = m.group('wikipath')
        wiki_path = m.group("wikipath")
        absolute = False
        if wiki_path.startswith("/"):
            absolute = True


@@ 71,7 67,7 @@ class WikiPath(markdown.inlinepatterns.Pattern):
        path_from_link = ""

        if absolute:
            base_path = self.config['base_url'][0]
            base_path = self.config["base_url"][0]
            path_from_link = os_path.join(str(base_path), wiki_path)

            urlpath = None


@@ 87,15 83,14 @@ class WikiPath(markdown.inlinepatterns.Pattern):
            # We take the first (self.config['default_level'] - 1) components, so adding
            # one more component would make a path of length
            # self.config['default_level']
            starting_level = max(0, self.config['default_level'][0] - 1)
            starting_path = "/".join(source_components[: starting_level])
            starting_level = max(0, self.config["default_level"][0] - 1)
            starting_path = "/".join(source_components[:starting_level])

            path_from_link = os_path.join(starting_path, wiki_path)

            lookup = models.URLPath.objects.none()
            if urlpath.parent:
                lookup = urlpath.parent.get_descendants().filter(
                    slug=wiki_path)
                lookup = urlpath.parent.get_descendants().filter(slug=wiki_path)
            else:
                lookup = urlpath.get_descendants().filter(slug=wiki_path)



@@ 104,30 99,30 @@ class WikiPath(markdown.inlinepatterns.Pattern):
                path = urlpath.get_absolute_url()
            else:
                urlpath = None
                path = self.config['base_url'][0] + path_from_link
                path = self.config["base_url"][0] + path_from_link

        label = m.group('label')
        fragment = m.group('fragment') or ""
        label = m.group("label")
        fragment = m.group("fragment") or ""

        a = etree.Element('a')
        a.set('href', path + fragment)
        a = etree.Element("a")
        a.set("href", path + fragment)
        if not urlpath:
            a.set('class', self.config['html_class'][0] + " linknotfound")
            a.set("class", self.config["html_class"][0] + " linknotfound")
        else:
            a.set('class', self.config['html_class'][0])
            a.set("class", self.config["html_class"][0])
        a.text = label

        return a

    def _getMeta(self):
        """ Return meta data or config data. """
        base_url = self.config['base_url'][0]
        html_class = self.config['html_class'][0]
        if hasattr(self.md, 'Meta'):
            if 'wiki_base_url' in self.md.Meta:
                base_url = self.md.Meta['wiki_base_url'][0]
            if 'wiki_html_class' in self.md.Meta:
                html_class = self.md.Meta['wiki_html_class'][0]
        base_url = self.config["base_url"][0]
        html_class = self.config["html_class"][0]
        if hasattr(self.md, "Meta"):
            if "wiki_base_url" in self.md.Meta:
                base_url = self.md.Meta["wiki_base_url"][0]
            if "wiki_html_class" in self.md.Meta:
                html_class = self.md.Meta["wiki_html_class"][0]
        return base_url, html_class



M src/wiki/plugins/links/mdx/urlize.py => src/wiki/plugins/links/mdx/urlize.py +37 -42
@@ 62,40 62,31 @@ import markdown
URLIZE_RE = (
    # Links must start at beginning of string, or be preceded with
    # whitespace, '(', or '<'.
    r'^(?P<begin>|.*?[\s\(\<])'

    r'(?P<url>'  # begin url group

    r"^(?P<begin>|.*?[\s\(\<])"
    r"(?P<url>"  # begin url group
    # Leading protocol specification.
    r'(?P<protocol>([A-Z][A-Z0-9+.-]*://|))'

    r"(?P<protocol>([A-Z][A-Z0-9+.-]*://|))"
    # Host identifier
    r'(?P<host>'  # begin host identifier group

    r'[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|'  # IPv4, match before FQDN
    r'\[?([A-F0-9]{1,4}:){7}([A-F0-9]{1,4})\]?|'  # IPv6, full form
    r'\[?:(:[A-F0-9]{1,4}){1,6}\]?|'  # IPv6, leading zeros removed
    r'([A-F0-9]{1,4}:){1,6}:([A-F0-9]{1,4}){1,6}|'  # IPv6, zeros in middle removed.
    r'\[?([A-F0-9]{1,4}:){1,6}:\]?|'  # IPv6, trailing zeros removed
    r'\[?::\]?|'  # IPv6, just "empty" address
    r'([A-Z0-9]([A-Z0-9-]{0,61}[A-Z0-9])?\.)+([A-Z]{2,6}\.?|[A-Z]{2,}\.?)'  # FQDN
    r')'  # end host identifier group

    r"(?P<host>"  # begin host identifier group
    r"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|"  # IPv4, match before FQDN
    r"\[?([A-F0-9]{1,4}:){7}([A-F0-9]{1,4})\]?|"  # IPv6, full form
    r"\[?:(:[A-F0-9]{1,4}){1,6}\]?|"  # IPv6, leading zeros removed
    r"([A-F0-9]{1,4}:){1,6}:([A-F0-9]{1,4}){1,6}|"  # IPv6, zeros in middle removed.
    r"\[?([A-F0-9]{1,4}:){1,6}:\]?|"  # IPv6, trailing zeros removed
    r"\[?::\]?|"  # IPv6, just "empty" address
    r"([A-Z0-9]([A-Z0-9-]{0,61}[A-Z0-9])?\.)+([A-Z]{2,6}\.?|[A-Z]{2,}\.?)"  # FQDN
    r")"  # end host identifier group
    # Optional port
    r'(:(?P<port>[0-9]+))?'

    r"(:(?P<port>[0-9]+))?"
    # Optional trailing slash with path and GET parameters.
    r'(/(?P<path>[^\s\[\(\]\)\<\>]*))?'

    r')'  # end url group

    r"(/(?P<path>[^\s\[\(\]\)\<\>]*))?"
    r")"  # end url group
    # Links must stop at end of string, or be followed by a whitespace, ')', or '>'.
    r'(?P<end>[\s\)\>].*?|)$'
    r"(?P<end>[\s\)\>].*?|)$"
)


class UrlizePattern(markdown.inlinepatterns.Pattern):

    def getCompiledRegExp(self):
        """
        Return compiled regular expression for matching the URL


@@ 111,41 102,45 @@ class UrlizePattern(markdown.inlinepatterns.Pattern):
        Processes match found within the text.
        """

        protocol = m.group('protocol')
        protocol = m.group("protocol")

        url = m.group('url')
        url = m.group("url")
        text = url

        begin_url = m.group('begin')
        end_url = m.group('end')
        begin_url = m.group("begin")
        end_url = m.group("end")

        # If opening and ending character for URL are not the same,
        # return text unchanged.
        if begin_url:
            begin_delimeter = begin_url[-1]
        else:
            begin_delimeter = ''
            begin_delimeter = ""
        if end_url:
            end_delimeter = end_url[0]
        else:
            end_delimeter = ''
            end_delimeter = ""

        if (
                begin_delimeter == '<' and end_delimeter != '>' or
                begin_delimeter == '(' and end_delimeter != ')' or
                end_delimeter == ')' and begin_delimeter != '(' or
                end_delimeter == '>' and begin_delimeter != '<'
            begin_delimeter == "<"
            and end_delimeter != ">"
            or begin_delimeter == "("
            and end_delimeter != ")"
            or end_delimeter == ")"
            and begin_delimeter != "("
            or end_delimeter == ">"
            and begin_delimeter != "<"
        ):
            return url

        # If no supported protocol is specified, assume plaintext http
        # and add it to the url.
        if protocol == '':
            url = 'http://' + url
        if protocol == "":
            url = "http://" + url

        # Convenience link to distinguish external links more easily.
        icon = markdown.util.etree.Element("span")
        icon.set('class', 'fa fa-external-link')
        icon.set("class", "fa fa-external-link")

        # Link text.
        span_text = markdown.util.etree.Element("span")


@@ 153,9 148,9 @@ class UrlizePattern(markdown.inlinepatterns.Pattern):

        # Set-up link itself.
        el = markdown.util.etree.Element("a")
        el.set('href', url)
        el.set('target', '_blank')
        el.set('rel', 'nofollow')
        el.set("href", url)
        el.set("target", "_blank")
        el.set("rel", "nofollow")
        el.append(icon)
        el.append(span_text)



@@ 170,7 165,7 @@ class UrlizeExtension(markdown.extensions.Extension):

    def extendMarkdown(self, md):
        """ Replace autolink with UrlizePattern """
        md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
        md.inlinePatterns["autolink"] = UrlizePattern(URLIZE_RE, md)


def makeExtension(*args, **kwargs):

M src/wiki/plugins/links/settings.py => src/wiki/plugins/links/settings.py +1 -1
@@ 6,4 6,4 @@ from django.conf import settings as django_settings
#: can be done by following the link. This link will be relative to
#: ``LOOKUP_LEVEL``. This should be the level that most articles are
#: created at.
LOOKUP_LEVEL = getattr(django_settings, 'WIKI_LINKS_LOOKUP_LEVEL', 2)
LOOKUP_LEVEL = getattr(django_settings, "WIKI_LINKS_LOOKUP_LEVEL", 2)

M src/wiki/plugins/links/views.py => src/wiki/plugins/links/views.py +12 -10
@@ 6,26 6,28 @@ from wiki.decorators import get_article


class QueryUrlPath(View):

    @method_decorator(get_article(can_read=True))
    def dispatch(self, request, article, *args, **kwargs):
        max_num = kwargs.pop('max_num', 20)
        query = request.GET.get('query', None)
        max_num = kwargs.pop("max_num", 20)
        query = request.GET.get("query", None)

        matches = []

        if query:
            matches = models.URLPath.objects.can_read(
                request.user).active().filter(
                article__current_revision__title__contains=query,
                article__current_revision__deleted=False,
            matches = (
                models.URLPath.objects.can_read(request.user)
                .active()
                .filter(
                    article__current_revision__title__contains=query,
                    article__current_revision__deleted=False,
                )
            )
            matches = matches.select_related_common()
            matches = [
                "[{title:s}](wiki:{url:s})".format(
                    title=m.article.current_revision.title,
                    url='/' + m.path.strip("/")
                ) for m in matches[:max_num]
                    title=m.article.current_revision.title, url="/" + m.path.strip("/")
                )
                for m in matches[:max_num]
            ]

        return object_to_json_response(matches)

M src/wiki/plugins/links/wiki_plugin.py => src/wiki/plugins/links/wiki_plugin.py +21 -17
@@ 9,27 9,31 @@ from wiki.plugins.links.mdx.urlize import makeExtension as urlize_makeExtension

class LinkPlugin(BasePlugin):

    slug = 'links'
    urlpatterns = {'article': [
        re_path(r'^json/query-urlpath/$',
            views.QueryUrlPath.as_view(),
            name='links_query_urlpath'),
    ]}

    sidebar = {'headline': _('Links'),
               'icon_class': 'fa-bookmark',
               'template': 'wiki/plugins/links/sidebar.html',
               'form_class': None,
               'get_form_kwargs': (lambda a: {})}
    slug = "links"
    urlpatterns = {
        "article": [
            re_path(
                r"^json/query-urlpath/$",
                views.QueryUrlPath.as_view(),
                name="links_query_urlpath",
            ),
        ]
    }

    sidebar = {
        "headline": _("Links"),
        "icon_class": "fa-bookmark",
        "template": "wiki/plugins/links/sidebar.html",
        "form_class": None,
        "get_form_kwargs": (lambda a: {}),
    }

    wikipath_config = [
        ('base_url', reverse_lazy('wiki:get', kwargs={'path': ''})),
        ('default_level', settings.LOOKUP_LEVEL),
        ("base_url", reverse_lazy("wiki:get", kwargs={"path": ""})),
        ("default_level", settings.LOOKUP_LEVEL),
    ]

    markdown_extensions = [
        urlize_makeExtension(),
        WikiPathExtension(wikipath_config)]
    markdown_extensions = [urlize_makeExtension(), WikiPathExtension(wikipath_config)]


registry.register(LinkPlugin)

M src/wiki/plugins/macros/__init__.py => src/wiki/plugins/macros/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.macros.apps.MacrosConfig'
default_app_config = "wiki.plugins.macros.apps.MacrosConfig"

M src/wiki/plugins/macros/apps.py => src/wiki/plugins/macros/apps.py +2 -2
@@ 3,6 3,6 @@ from django.utils.translation import gettext_lazy as _


class MacrosConfig(AppConfig):
    name = 'wiki.plugins.macros'
    name = "wiki.plugins.macros"
    verbose_name = _("Wiki macros")
    label = 'wiki_macros'
    label = "wiki_macros"

M src/wiki/plugins/macros/mdx/macro.py => src/wiki/plugins/macros/mdx/macro.py +29 -25
@@ 11,9 11,8 @@ re_sq_short = r"'([^'\\]*(?:\\.[^'\\]*)*)'"

MACRO_RE = r"((?i)\[(?P<macro>\w+)(?P<kwargs>\s\w+\:.+)*\])"
KWARG_RE = re.compile(
    r'\s*(?P<arg>\w+)(:(?P<value>([^\']+|%s)))?' %
    re_sq_short,
    re.IGNORECASE)
    r"\s*(?P<arg>\w+)(:(?P<value>([^\']+|%s)))?" % re_sq_short, re.IGNORECASE
)


class MacroExtension(markdown.Extension):


@@ 21,7 20,7 @@ class MacroExtension(markdown.Extension):
    """ Macro plugin markdown extension for django-wiki. """

    def extendMarkdown(self, md):
        md.inlinePatterns.add('dw-macros', MacroPattern(MACRO_RE, md), '>link')
        md.inlinePatterns.add("dw-macros", MacroPattern(MACRO_RE, md), ">link")


class MacroPattern(markdown.inlinepatterns.Pattern):


@@ 30,17 29,17 @@ class MacroPattern(markdown.inlinepatterns.Pattern):
    [some_macro (kw:arg)*] references. """

    def handleMatch(self, m):
        macro = m.group('macro').strip()
        macro = m.group("macro").strip()
        if macro not in settings.METHODS or not hasattr(self, macro):
            return m.group(2)

        kwargs = m.group('kwargs')
        kwargs = m.group("kwargs")
        if not kwargs:
            return getattr(self, macro)()
        kwargs_dict = {}
        for kwarg in KWARG_RE.finditer(kwargs):
            arg = kwarg.group('arg')
            value = kwarg.group('value')
            arg = kwarg.group("arg")
            value = kwarg.group("value")
            if value is None:
                value = True
            if isinstance(value, str):


@@ 58,35 57,40 @@ class MacroPattern(markdown.inlinepatterns.Pattern):
        html = render_to_string(
            "wiki/plugins/macros/article_list.html",
            context={
                'article_children': self.markdown.article.get_children(
                    article__current_revision__deleted=False),
                'depth': int(depth) + 1,
            })
                "article_children": self.markdown.article.get_children(
                    article__current_revision__deleted=False
                ),
                "depth": int(depth) + 1,
            },
        )
        return self.markdown.htmlStash.store(html)

    article_list.meta = dict(
        short_description=_('Article list'),
        help_text=_('Insert a list of articles in this level.'),
        example_code='[article_list depth:2]',
        args={'depth': _('Maximum depth to show levels for.')}
        short_description=_("Article list"),
        help_text=_("Insert a list of articles in this level."),
        example_code="[article_list depth:2]",
        args={"depth": _("Maximum depth to show levels for.")},
    )

    def toc(self):
        return "[TOC]"

    toc.meta = dict(
        short_description=_('Table of contents'),
        help_text=_('Insert a table of contents matching the headings.'),
        example_code='[TOC]',
        args={}
        short_description=_("Table of contents"),
        help_text=_("Insert a table of contents matching the headings."),
        example_code="[TOC]",
        args={},
    )

    def wikilink(self):
        return ""

    wikilink.meta = dict(
        short_description=_('WikiLinks'),
        help_text=_(
            'Insert a link to another wiki page with a short notation.'),
        example_code='[[WikiLink]]',
        args={})
        short_description=_("WikiLinks"),
        help_text=_("Insert a link to another wiki page with a short notation."),
        example_code="[[WikiLink]]",
        args={},
    )


def makeExtension(*args, **kwargs):

M src/wiki/plugins/macros/mdx/toc.py => src/wiki/plugins/macros/mdx/toc.py +2 -3
@@ 11,7 11,6 @@ def wiki_slugify(*args, **kwargs):


class WikiTreeProcessorClass(TocTreeprocessor):

    def run(self, doc):
        # Necessary because self.title is set to a LazyObject via gettext_lazy
        if self.title:


@@ 23,11 22,11 @@ class WikiTocExtension(TocExtension):
    TreeProcessorClass = WikiTreeProcessorClass

    def __init__(self, **kwargs):
        kwargs.setdefault('slugify', wiki_slugify)
        kwargs.setdefault("slugify", wiki_slugify)
        super().__init__(**kwargs)

    def extendMarkdown(self, md):
        if 'toc' in settings.METHODS:
        if "toc" in settings.METHODS:
            TocExtension.extendMarkdown(self, md)



M src/wiki/plugins/macros/mdx/wikilinks.py => src/wiki/plugins/macros/mdx/wikilinks.py +14 -16
@@ 10,27 10,26 @@ from markdown.extensions import Extension, wikilinks

def build_url(label, base, end, md):
    """ Build a url from the label, a base, and an end. """
    clean_label = re.sub(r'([ ]+_)|(_[ ]+)|([ ]+)', '_', label)
    clean_label = re.sub(r"([ ]+_)|(_[ ]+)|([ ]+)", "_", label)
    urlpaths = md.article.urlpath_set.all()
    # Nevermind about the base we are fed, just keep the original
    # call pattern from the wikilinks plugin for later...
    base = reverse('wiki:get', kwargs={'path': ''})
    base = reverse("wiki:get", kwargs={"path": ""})
    for urlpath in urlpaths:
        if urlpath.children.filter(slug=clean_label).exists():
            base = ''
            base = ""
            break
    return '%s%s%s' % (base, clean_label, end)
    return "%s%s%s" % (base, clean_label, end)


class WikiLinkExtension(Extension):

    def __init__(self, **kwargs):
        # set extension defaults
        self.config = {
            'base_url': ['', 'String to append to beginning or URL.'],
            'end_url': ['/', 'String to append to end of URL.'],
            'html_class': ['wiki_wikilink', 'CSS hook. Leave blank for none.'],
            'build_url': [build_url, 'Callable formats URL from label.'],
            "base_url": ["", "String to append to beginning or URL."],
            "end_url": ["/", "String to append to end of URL."],
            "html_class": ["wiki_wikilink", "CSS hook. Leave blank for none."],
            "build_url": [build_url, "Callable formats URL from label."],
        }
        super().__init__(**kwargs)



@@ 38,23 37,22 @@ class WikiLinkExtension(Extension):
        self.md = md

        # append to end of inline patterns
        WIKILINK_RE = r'\[\[([\w0-9_ -]+)\]\]'
        WIKILINK_RE = r"\[\[([\w0-9_ -]+)\]\]"
        wikilinkPattern = WikiLinks(WIKILINK_RE, self.getConfigs())
        wikilinkPattern.md = md
        md.inlinePatterns.add('wikilink', wikilinkPattern, "<not_strong")
        md.inlinePatterns.add("wikilink", wikilinkPattern, "<not_strong")


class WikiLinks(wikilinks.WikiLinksInlineProcessor):

    def handleMatch(self, m, data):
        base_url, end_url, html_class = self._getMeta()
        label = m.group(1).strip()
        url = self.config['build_url'](label, base_url, end_url, self.md)
        a = markdown.util.etree.Element('a')
        url = self.config["build_url"](label, base_url, end_url, self.md)
        a = markdown.util.etree.Element("a")
        a.text = label
        a.set('href', url)
        a.set("href", url)
        if html_class:
            a.set('class', html_class)
            a.set("class", html_class)
        return a, m.start(0), m.end(0)



M src/wiki/plugins/macros/settings.py => src/wiki/plugins/macros/settings.py +3 -8
@@ 1,14 1,9 @@
from django.conf import settings as django_settings

SLUG = 'macros'
APP_LABEL = 'wiki'
SLUG = "macros"
APP_LABEL = "wiki"

#: List of markdown extensions this plugin should support.
#: ``article_list`` inserts a list of articles from the current level.
#: ``toc`` inserts a table of contents matching the headings.
METHODS = getattr(
    django_settings,
    'WIKI_PLUGINS_METHODS',
    ('article_list',
     'toc',
     ))
METHODS = getattr(django_settings, "WIKI_PLUGINS_METHODS", ("article_list", "toc",))

M src/wiki/plugins/macros/templatetags/wiki_macro_tags.py => src/wiki/plugins/macros/templatetags/wiki_macro_tags.py +3 -4
@@ 6,12 6,11 @@ register = template.Library()


@register.inclusion_tag(
    'wiki/plugins/templatetags/article_list.html',
    takes_context=True
    "wiki/plugins/templatetags/article_list.html", takes_context=True
)
def article_list(context, urlpath, depth):
    context['parent'] = urlpath
    context['depth'] = depth
    context["parent"] = urlpath
    context["depth"] = depth
    return context



M src/wiki/plugins/macros/wiki_plugin.py => src/wiki/plugins/macros/wiki_plugin.py +10 -8
@@ 8,16 8,18 @@ class MacroPlugin(BasePlugin):

    slug = settings.SLUG

    sidebar = {'headline': _('Macros'),
               'icon_class': 'fa-play',
               'template': 'wiki/plugins/macros/sidebar.html',
               'form_class': None,
               'get_form_kwargs': (lambda a: {})}
    sidebar = {
        "headline": _("Macros"),
        "icon_class": "fa-play",
        "template": "wiki/plugins/macros/sidebar.html",
        "form_class": None,
        "get_form_kwargs": (lambda a: {}),
    }

    markdown_extensions = [
        'wiki.plugins.macros.mdx.macro',
        'wiki.plugins.macros.mdx.toc',
        'wiki.plugins.macros.mdx.wikilinks',
        "wiki.plugins.macros.mdx.macro",
        "wiki.plugins.macros.mdx.toc",
        "wiki.plugins.macros.mdx.wikilinks",
    ]



M src/wiki/plugins/notifications/__init__.py => src/wiki/plugins/notifications/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.notifications.apps.NotificationsConfig'
default_app_config = "wiki.plugins.notifications.apps.NotificationsConfig"

M src/wiki/plugins/notifications/apps.py => src/wiki/plugins/notifications/apps.py +17 -13
@@ 4,9 4,9 @@ from django.utils.translation import gettext_lazy as _


class NotificationsConfig(AppConfig):
    name = 'wiki.plugins.notifications'
    name = "wiki.plugins.notifications"
    verbose_name = _("Wiki notifications")
    label = 'wiki_notifications'
    label = "wiki_notifications"

    def ready(self):
        """


@@ 19,27 19,31 @@ class NotificationsConfig(AppConfig):

        for plugin in registry.get_plugins():

            notifications = getattr(plugin, 'notifications', [])
            notifications = getattr(plugin, "notifications", [])
            for notification_dict in notifications:

                @disable_signal_for_loaddata
                def plugin_notification(instance, **kwargs):
                    if notification_dict.get('ignore', lambda x: False)(instance):
                    if notification_dict.get("ignore", lambda x: False)(instance):
                        return
                    if kwargs.get('created', False) == notification_dict.get('created', True):
                        if 'get_url' in notification_dict:
                            url = notification_dict['get_url'](instance)
                    if kwargs.get("created", False) == notification_dict.get(
                        "created", True
                    ):
                        if "get_url" in notification_dict:
                            url = notification_dict["get_url"](instance)
                        else:
                            url = models.default_url(notification_dict['get_article'](instance))
                            url = models.default_url(
                                notification_dict["get_article"](instance)
                            )

                        message = notification_dict['message'](instance)
                        message = notification_dict["message"](instance)
                        notify(
                            message,
                            notification_dict['key'],
                            target_object=notification_dict['get_article'](instance),
                            notification_dict["key"],
                            target_object=notification_dict["get_article"](instance),
                            url=url,
                        )

                signals.post_save.connect(
                    plugin_notification,
                    sender=notification_dict['model']
                    plugin_notification, sender=notification_dict["model"]
                )

M src/wiki/plugins/notifications/forms.py => src/wiki/plugins/notifications/forms.py +63 -75
@@ 10,50 10,48 @@ from wiki.plugins.notifications.settings import ARTICLE_EDIT


class SettingsModelChoiceField(forms.ModelChoiceField):

    def label_from_instance(self, obj):
        return gettext(
            "Receive notifications %(interval)s"
        ) % {
            'interval': obj.get_interval_display()
        return gettext("Receive notifications %(interval)s") % {
            "interval": obj.get_interval_display()
        }


class ArticleSubscriptionModelMultipleChoiceField(
        forms.ModelMultipleChoiceField):

class ArticleSubscriptionModelMultipleChoiceField(forms.ModelMultipleChoiceField):
    def label_from_instance(self, obj):
        return gettext("%(title)s - %(url)s") % {
            'title': obj.article.current_revision.title,
            'url': obj.article.get_absolute_url()
            "title": obj.article.current_revision.title,
            "url": obj.article.get_absolute_url(),
        }


class SettingsModelForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop('user')
        self.user = kwargs.pop("user")
        super().__init__(*args, **kwargs)
        instance = kwargs.get('instance', None)
        instance = kwargs.get("instance", None)
        self.__editing_instance = False
        if instance:
            self.__editing_instance = True
            self.fields['delete_subscriptions'] = ArticleSubscriptionModelMultipleChoiceField(
            self.fields[
                "delete_subscriptions"
            ] = ArticleSubscriptionModelMultipleChoiceField(
                models.ArticleSubscription.objects.filter(
                    subscription__settings=instance,
                    article__current_revision__deleted=False,
                ),
                label=gettext("Remove subscriptions"),
                required=False,
                help_text=gettext("Select article subscriptions to remove from notifications"),
                help_text=gettext(
                    "Select article subscriptions to remove from notifications"
                ),
                initial=models.ArticleSubscription.objects.none(),
            )
            self.fields['email'] = forms.TypedChoiceField(
            self.fields["email"] = forms.TypedChoiceField(
                label=_("Email digests"),
                choices=(
                    (0, gettext('Unchanged (selected on each article)')),
                    (1, gettext('No emails')),
                    (2, gettext('Email on any change')),
                    (0, gettext("Unchanged (selected on each article)")),
                    (1, gettext("No emails")),
                    (2, gettext("Email on any change")),
                ),
                coerce=lambda x: int(x) if x is not None else None,
                widget=forms.RadioSelect(),


@@ 65,26 63,21 @@ class SettingsModelForm(forms.ModelForm):
        instance = super().save(*args, commit=False, **kwargs)
        instance.user = self.user
        if self.__editing_instance:
            self.cleaned_data['delete_subscriptions'].delete()
            if self.cleaned_data['email'] == 1:
                instance.subscription_set.all().update(
                    send_emails=False,
                )
            elif self.cleaned_data['email'] == 2:
                instance.subscription_set.all().update(
                    send_emails=True,
                )
            self.cleaned_data["delete_subscriptions"].delete()
            if self.cleaned_data["email"] == 1:
                instance.subscription_set.all().update(send_emails=False,)
            elif self.cleaned_data["email"] == 2:
                instance.subscription_set.all().update(send_emails=True,)
        instance.save()
        return instance


class BaseSettingsFormSet(BaseModelFormSet):

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop('user')
        self.user = kwargs.pop("user")

        # Ensure that at least 1 default settings object exists
        all_settings = Settings.objects.filter(user=self.user).order_by('is_default')
        all_settings = Settings.objects.filter(user=self.user).order_by("is_default")
        if not all_settings.exists():
            Settings.objects.create(user=self.user, is_default=True)
        else:


@@ 95,13 88,15 @@ class BaseSettingsFormSet(BaseModelFormSet):
        super().__init__(*args, **kwargs)

    def get_queryset(self):
        return Settings.objects.filter(
            user=self.user,
        ).exclude(
            subscription__articlesubscription__article__current_revision__deleted=True,
        ).prefetch_related(
            'subscription_set__articlesubscription',
        ).order_by('is_default').distinct()
        return (
            Settings.objects.filter(user=self.user,)
            .exclude(
                subscription__articlesubscription__article__current_revision__deleted=True,
            )
            .prefetch_related("subscription_set__articlesubscription",)
            .order_by("is_default")
            .distinct()
        )


SettingsFormSet = modelformset_factory(


@@ 109,42 104,36 @@ SettingsFormSet = modelformset_factory(
    form=SettingsModelForm,
    formset=BaseSettingsFormSet,
    extra=0,
    fields=('interval', ),
    fields=("interval",),
)


class SubscriptionForm(PluginSettingsFormMixin, forms.Form):

    settings_form_headline = _('Notifications')
    settings_form_headline = _("Notifications")
    settings_order = 1
    settings_write_access = False

    settings = SettingsModelChoiceField(
        None,
        empty_label=None,
        label=_('Settings')
    )
    edit = forms.BooleanField(
        required=False,
        label=_('When this article is edited')
    )
    settings = SettingsModelChoiceField(None, empty_label=None, label=_("Settings"))
    edit = forms.BooleanField(required=False, label=_("When this article is edited"))
    edit_email = forms.BooleanField(
        required=False, label=_('Also receive emails about article edits'),
        required=False,
        label=_("Also receive emails about article edits"),
        widget=forms.CheckboxInput(
            attrs={
                'onclick':
                mark_safe("$('#id_edit').attr('checked', $(this).is(':checked'));")
                "onclick": mark_safe(
                    "$('#id_edit').attr('checked', $(this).is(':checked'));"
                )
            }
        )
        ),
    )

    def __init__(self, article, request, *args, **kwargs):
        self.article = article
        self.user = request.user
        initial = kwargs.pop('initial', None)
        initial = kwargs.pop("initial", None)
        self.notification_type = NotificationType.objects.get_or_create(
            key=ARTICLE_EDIT,
            content_type=ContentType.objects.get_for_model(article)
            key=ARTICLE_EDIT, content_type=ContentType.objects.get_for_model(article)
        )[0]
        self.edit_notifications = models.ArticleSubscription.objects.filter(
            article=article,


@@ 153,50 142,49 @@ class SubscriptionForm(PluginSettingsFormMixin, forms.Form):
        )
        self.default_settings = Settings.get_default_setting(request.user)
        if self.edit_notifications:
            self.default_settings = self.edit_notifications[
                0].subscription.settings
            self.default_settings = self.edit_notifications[0].subscription.settings
        if not initial:
            initial = {
                'edit': bool(self.edit_notifications),
                'edit_email': bool(self.edit_notifications.filter(subscription__send_emails=True)),
                'settings': self.default_settings,
                "edit": bool(self.edit_notifications),
                "edit_email": bool(
                    self.edit_notifications.filter(subscription__send_emails=True)
                ),
                "settings": self.default_settings,
            }
        kwargs['initial'] = initial
        kwargs["initial"] = initial
        super().__init__(*args, **kwargs)
        self.fields['settings'].queryset = Settings.objects.filter(
            user=request.user,
        )
        self.fields["settings"].queryset = Settings.objects.filter(user=request.user,)

    def get_usermessage(self):
        if self.changed_data:
            return _('Your notification settings were updated.')
            return _("Your notification settings were updated.")
        else:
            return _(
                'Your notification settings were unchanged, so nothing saved.')
            return _("Your notification settings were unchanged, so nothing saved.")

    def save(self, *args, **kwargs):
        if not self.changed_data:
            return
        if self.cleaned_data['edit']:
        if self.cleaned_data["edit"]:
            try:
                edit_notification = models.ArticleSubscription.objects.get(
                    subscription__notification_type=self.notification_type,
                    article=self.article,
                    subscription__settings=self.cleaned_data['settings'],
                    subscription__settings=self.cleaned_data["settings"],
                )
                edit_notification.subscription.send_emails = self.cleaned_data['edit_email']
                edit_notification.subscription.send_emails = self.cleaned_data[
                    "edit_email"
                ]
                edit_notification.subscription.save()
            except models.ArticleSubscription.DoesNotExist:
                subscription, __ = Subscription.objects.get_or_create(
                    settings=self.cleaned_data['settings'],
                    settings=self.cleaned_data["settings"],
                    notification_type=self.notification_type,
                    object_id=self.article.id,
                )
                models.ArticleSubscription.objects.create(
                    subscription=subscription,
                    article=self.article,
                    subscription=subscription, article=self.article,
                )
                subscription.send_emails = self.cleaned_data['edit_email']
                subscription.send_emails = self.cleaned_data["edit_email"]
                subscription.save()

        else:

M src/wiki/plugins/notifications/management/commands/wiki_notifications_create_defaults.py => src/wiki/plugins/notifications/management/commands/wiki_notifications_create_defaults.py +23 -18
@@ 10,9 10,9 @@ from wiki.plugins.notifications.settings import ARTICLE_EDIT


class Command(BaseCommand):
    args = '[file-name.csv]'
    args = "[file-name.csv]"
    # @ReservedAssignment
    help = 'Import and parse messages directly from a CSV file.'
    help = "Import and parse messages directly from a CSV file."

    def handle(self, *args, **options):
        from django.conf import settings


@@ 24,14 24,14 @@ class Command(BaseCommand):

            def subscribe_to_article(article, user):
                if user not in settings_map:
                    settings_map[user], __ = Settings.objects.get_or_create(
                        user=user)
                    settings_map[user], __ = Settings.objects.get_or_create(user=user)

                return subscribe(
                    settings_map[user],
                    ARTICLE_EDIT,
                    content_type=ContentType.objects.get_for_model(article),
                    object_id=article.id)
                    object_id=article.id,
                )

            subs = 0
            articles = Article.objects.all()


@@ 39,20 39,25 @@ class Command(BaseCommand):
                if article.owner:
                    subscription = subscribe_to_article(article, article.owner)
                    models.ArticleSubscription.objects.get_or_create(
                        article=article,
                        subscription=subscription)
                        article=article, subscription=subscription
                    )
                    subs += 1
                for revision in article.articlerevision_set.exclude(
                        user=article.owner).exclude(
                        user=None).values('user').distinct():
                    user = get_user_model().objects.get(id=revision['user'])
                for revision in (
                    article.articlerevision_set.exclude(user=article.owner)
                    .exclude(user=None)
                    .values("user")
                    .distinct()
                ):
                    user = get_user_model().objects.get(id=revision["user"])
                    subs += 1
                    subscription = subscribe_to_article(article, user)
                    models.ArticleSubscription.objects.get_or_create(
                        article=article,
                        subscription=subscription)

            self.stdout.write("Created {subs:d} subscriptions on  {arts:d} articles".format(
                subs=subs,
                arts=articles.count(),
            ), ending='\n')
                        article=article, subscription=subscription
                    )

            self.stdout.write(
                "Created {subs:d} subscriptions on  {arts:d} articles".format(
                    subs=subs, arts=articles.count(),
                ),
                ending="\n",
            )

M src/wiki/plugins/notifications/migrations/0001_initial.py => src/wiki/plugins/notifications/migrations/0001_initial.py +24 -10
@@ 4,23 4,37 @@ from django.db import migrations, models
class Migration(migrations.Migration):

    dependencies = [
        ('django_nyt', '0006_auto_20141229_1630'),
        ('wiki', '0001_initial'),
        ("django_nyt", "0006_auto_20141229_1630"),
        ("wiki", "0001_initial"),
    ]

    operations = [
        migrations.CreateModel(
            name='ArticleSubscription',
            name="ArticleSubscription",
            fields=[
                ('articleplugin_ptr', models.OneToOneField(auto_created=True, to='wiki.ArticlePlugin', primary_key=True, parent_link=True, serialize=False, on_delete=models.CASCADE)),
                ('subscription', models.OneToOneField(to='django_nyt.Subscription', on_delete=models.CASCADE)),
                (
                    "articleplugin_ptr",
                    models.OneToOneField(
                        auto_created=True,
                        to="wiki.ArticlePlugin",
                        primary_key=True,
                        parent_link=True,
                        serialize=False,
                        on_delete=models.CASCADE,
                    ),
                ),
                (
                    "subscription",
                    models.OneToOneField(
                        to="django_nyt.Subscription", on_delete=models.CASCADE
                    ),
                ),
            ],
            options={
            },
            bases=('wiki.articleplugin',),
            options={},
            bases=("wiki.articleplugin",),
        ),
        migrations.AlterUniqueTogether(
            name='articlesubscription',
            unique_together=set([('subscription', 'articleplugin_ptr')]),
            name="articlesubscription",
            unique_together=set([("subscription", "articleplugin_ptr")]),
        ),
    ]

M src/wiki/plugins/notifications/migrations/0002_auto_20151118_1811.py => src/wiki/plugins/notifications/migrations/0002_auto_20151118_1811.py +2 -3
@@ 6,12 6,11 @@ class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('wiki_notifications', '0001_initial'),
        ("wiki_notifications", "0001_initial"),
    ]

    operations = [
        migrations.AlterModelTable(
            name='articlesubscription',
            table='wiki_notifications_articlesubscription',
            name="articlesubscription", table="wiki_notifications_articlesubscription",
        ),
    ]

M src/wiki/plugins/notifications/models.py => src/wiki/plugins/notifications/models.py +21 -21
@@ 16,61 16,61 @@ class ArticleSubscription(ArticlePlugin):
    subscription = models.OneToOneField(Subscription, on_delete=models.CASCADE)

    def __str__(self):
        title = (_("%(user)s subscribing to %(article)s (%(type)s)") %
                 {'user': self.subscription.settings.user.username,
                  'article': self.article.current_revision.title,
                  'type': self.subscription.notification_type.label})
        title = _("%(user)s subscribing to %(article)s (%(type)s)") % {
            "user": self.subscription.settings.user.username,
            "article": self.article.current_revision.title,
            "type": self.subscription.notification_type.label,
        }
        return str(title)

    class Meta:
        unique_together = ('subscription', 'articleplugin_ptr')
        unique_together = ("subscription", "articleplugin_ptr")
        # Matches label of upcoming 0.1 release
        db_table = 'wiki_notifications_articlesubscription'
        db_table = "wiki_notifications_articlesubscription"


def default_url(article, urlpath=None):
    if urlpath:
        return reverse('wiki:get', kwargs={'path': urlpath.path})
        return reverse("wiki:get", kwargs={"path": urlpath.path})
    return article.get_absolute_url()


@disable_signal_for_loaddata
def post_article_revision_save(**kwargs):
    instance = kwargs['instance']
    if kwargs.get('created', False):
    instance = kwargs["instance"]
    if kwargs.get("created", False):
        url = default_url(instance.article)
        filter_exclude = {'settings__user': instance.user}
        filter_exclude = {"settings__user": instance.user}
        if instance.deleted:
            notify(
                _('Article deleted: %s') %
                get_title(instance),
                _("Article deleted: %s") % get_title(instance),
                settings.ARTICLE_EDIT,
                target_object=instance.article,
                url=url,
                filter_exclude=filter_exclude)
                filter_exclude=filter_exclude,
            )
        elif instance.previous_revision:
            notify(
                _('Article modified: %s') %
                get_title(instance),
                _("Article modified: %s") % get_title(instance),
                settings.ARTICLE_EDIT,
                target_object=instance.article,
                url=url,
                filter_exclude=filter_exclude)
                filter_exclude=filter_exclude,
            )
        else:
            notify(
                _('New article created: %s') %
                get_title(instance),
                _("New article created: %s") % get_title(instance),
                settings.ARTICLE_EDIT,
                target_object=instance,
                url=url,
                filter_exclude=filter_exclude)
                filter_exclude=filter_exclude,
            )


# Whenever a new revision is created, we notifý users that an article
# was edited
signals.post_save.connect(
    post_article_revision_save,
    sender=wiki_models.ArticleRevision,
    post_article_revision_save, sender=wiki_models.ArticleRevision,
)

# TODO: We should notify users when the current_revision of an article is

M src/wiki/plugins/notifications/settings.py => src/wiki/plugins/notifications/settings.py +1 -2
@@ 1,8 1,7 @@

# Deprecated
APP_LABEL = None

# Key for django_nyt - changing it will break any existing notifications.
ARTICLE_EDIT = "article_edit"

SLUG = 'notifications'
SLUG = "notifications"

M src/wiki/plugins/notifications/views.py => src/wiki/plugins/notifications/views.py +25 -19
@@ 10,7 10,7 @@ from . import forms, models

class NotificationSettings(FormView):

    template_name = 'wiki/plugins/notifications/settings.html'
    template_name = "wiki/plugins/notifications/settings.html"
    form_class = forms.SettingsFormSet

    @method_decorator(login_required)


@@ 22,33 22,39 @@ class NotificationSettings(FormView):
            settings = form.save()
            messages.info(
                self.request,
                _("You will receive notifications %(interval)s for "
                  "%(articles)d articles") % {
                    'interval': settings.get_interval_display(),
                    'articles': self.get_article_subscriptions(form.instance).count(),
                }
                _(
                    "You will receive notifications %(interval)s for "
                    "%(articles)d articles"
                )
                % {
                    "interval": settings.get_interval_display(),
                    "articles": self.get_article_subscriptions(form.instance).count(),
                },
            )
        return redirect('wiki:notification_settings')
        return redirect("wiki:notification_settings")

    def get_article_subscriptions(self, nyt_settings):
        return models.ArticleSubscription.objects.filter(
            subscription__settings=nyt_settings,
            article__current_revision__deleted=False,
        ).select_related(
            'article',
            'article__current_revision'
        ).distinct()
        return (
            models.ArticleSubscription.objects.filter(
                subscription__settings=nyt_settings,
                article__current_revision__deleted=False,
            )
            .select_related("article", "article__current_revision")
            .distinct()
        )

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['user'] = self.request.user
        kwargs['form_kwargs'] = {'user': self.request.user}
        kwargs["user"] = self.request.user
        kwargs["form_kwargs"] = {"user": self.request.user}
        return kwargs

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['formset'] = context['form']
        for form in context['formset']:
        context["formset"] = context["form"]
        for form in context["formset"]:
            if form.instance:
                form.instance.articlesubscriptions = self.get_article_subscriptions(form.instance)
                form.instance.articlesubscriptions = self.get_article_subscriptions(
                    form.instance
                )
        return context

M src/wiki/plugins/notifications/wiki_plugin.py => src/wiki/plugins/notifications/wiki_plugin.py +10 -4
@@ 8,13 8,19 @@ from . import settings, views
class NotifyPlugin(BasePlugin):

    slug = settings.SLUG
    urlpatterns = {'root': [
        re_path(r'^$', views.NotificationSettings.as_view(), name='notification_settings'),
    ]}
    urlpatterns = {
        "root": [
            re_path(
                r"^$",
                views.NotificationSettings.as_view(),
                name="notification_settings",
            ),
        ]
    }

    article_view = views.NotificationSettings().dispatch

    settings_form = 'wiki.plugins.notifications.forms.SubscriptionForm'
    settings_form = "wiki.plugins.notifications.forms.SubscriptionForm"


registry.register(NotifyPlugin)

M src/wiki/plugins/redlinks/__init__.py => src/wiki/plugins/redlinks/__init__.py +1 -1
@@ 1,1 1,1 @@
default_app_config = 'wiki.plugins.redlinks.apps.RedlinksConfig'
default_app_config = "wiki.plugins.redlinks.apps.RedlinksConfig"

M src/wiki/plugins/redlinks/apps.py => src/wiki/plugins/redlinks/apps.py +2 -2
@@ 3,6 3,6 @@ from django.utils.translation import gettext_lazy as _


class RedlinksConfig(AppConfig):
    name = 'wiki.plugins.redlinks'
    name = "wiki.plugins.redlinks"
    verbose_name = _("Wiki red links")
    label = 'wiki_redlinks'
    label = "wiki_redlinks"

M src/wiki/plugins/redlinks/mdx/redlinks.py => src/wiki/plugins/redlinks/mdx/redlinks.py +1 -1
@@ 25,7 25,7 @@ def urljoin_internal(base, url):
    res1 = urljoin(canary1 + base, url)
    res2 = urljoin(canary2 + base, url)
    if res1.startswith(canary1) and res2.startswith(canary2):
        return res1[len(canary1):]
        return res1[len(canary1) :]


class LinkTreeprocessor(Treeprocessor):

M src/wiki/plugins/redlinks/wiki_plugin.py => src/wiki/plugins/redlinks/wiki_plugin.py +1 -1
@@ 5,7 5,7 @@ from wiki.core.plugins.base import BasePlugin
class Plugin(BasePlugin):

    markdown_extensions = [
        'wiki.plugins.redlinks.mdx.redlinks',
        "wiki.plugins.redlinks.mdx.redlinks",
    ]



M src/wiki/sites.py => src/wiki/sites.py +162 -70
@@ 16,43 16,77 @@ class WikiSite:
    of your customized site.
    """

    def __init__(self, name='wiki'):
    def __init__(self, name="wiki"):
        from wiki.views import accounts, article, deleted_list

        self.name = name

        # root view
        self.root_view = getattr(self, "root_view", article.CreateRootView.as_view())
        self.root_missing_view = getattr(self, "root_missing_view", article.MissingRootView.as_view())
        self.root_missing_view = getattr(
            self, "root_missing_view", article.MissingRootView.as_view()
        )

        # basic views
        self.article_view = getattr(self, "article_view", article.ArticleView.as_view())
        self.article_create_view = getattr(self, "article_create_view", article.Create.as_view())
        self.article_delete_view = getattr(self, "article_delete_view", article.Delete.as_view())
        self.article_deleted_view = getattr(self, "article_deleted_view", article.Deleted.as_view())
        self.article_create_view = getattr(
            self, "article_create_view", article.Create.as_view()
        )
        self.article_delete_view = getattr(
            self, "article_delete_view", article.Delete.as_view()
        )
        self.article_deleted_view = getattr(
            self, "article_deleted_view", article.Deleted.as_view()
        )
        self.article_dir_view = getattr(self, "article_dir_view", article.Dir.as_view())
        self.article_edit_view = getattr(self, "article_edit_view", article.Edit.as_view())
        self.article_move_view = getattr(self, "article_move_view", article.Move.as_view())
        self.article_preview_view = getattr(self, "article_preview_view", article.Preview.as_view())
        self.article_history_view = getattr(self, "article_history_view", article.History.as_view())
        self.article_settings_view = getattr(self, "article_settings_view", article.Settings.as_view())
        self.article_source_view = getattr(self, "article_source_view", article.Source.as_view())
        self.article_plugin_view = getattr(self, "article_plugin_view", article.Plugin.as_view())
        self.revision_change_view = getattr(self, "revision_change_view", article.ChangeRevisionView.as_view())
        self.revision_merge_view = getattr(self, "revision_merge_view", article.MergeView.as_view())
        self.revision_preview_merge_view = getattr(self, "revision_preview_merge_view", article.MergeView.as_view(preview=True))
        self.article_edit_view = getattr(
            self, "article_edit_view", article.Edit.as_view()
        )
        self.article_move_view = getattr(
            self, "article_move_view", article.Move.as_view()
        )
        self.article_preview_view = getattr(
            self, "article_preview_view", article.Preview.as_view()
        )
        self.article_history_view = getattr(
            self, "article_history_view", article.History.as_view()
        )
        self.article_settings_view = getattr(
            self, "article_settings_view", article.Settings.as_view()
        )
        self.article_source_view = getattr(
            self, "article_source_view", article.Source.as_view()
        )
        self.article_plugin_view = getattr(
            self, "article_plugin_view", article.Plugin.as_view()
        )
        self.revision_change_view = getattr(
            self, "revision_change_view", article.ChangeRevisionView.as_view()
        )
        self.revision_merge_view = getattr(
            self, "revision_merge_view", article.MergeView.as_view()
        )
        self.revision_preview_merge_view = getattr(
            self, "revision_preview_merge_view", article.MergeView.as_view(preview=True)
        )

        self.search_view = getattr(self, "search_view", article.SearchView.as_view())
        self.article_diff_view = getattr(self, "article_diff_view", article.DiffView.as_view())
        self.article_diff_view = getattr(
            self, "article_diff_view", article.DiffView.as_view()
        )

        # account views
        self.signup_view = getattr(self, "signup_view", accounts.Signup.as_view())
        self.login_view = getattr(self, "login_view", accounts.Login.as_view())
        self.logout_view = getattr(self, "logout_view", accounts.Logout.as_view())
        self.profile_update_view = getattr(self, "profile_update_view", accounts.Update.as_view())
        self.profile_update_view = getattr(
            self, "profile_update_view", accounts.Update.as_view()
        )

        # deleted list view
        self.deleted_list_view = getattr(self, "deleted_list_view", deleted_list.DeletedListView.as_view())
        self.deleted_list_view = getattr(
            self, "deleted_list_view", deleted_list.DeletedListView.as_view()
        )

    def get_urls(self):
        urlpatterns = self.get_root_urls()


@@ 69,31 103,39 @@ class WikiSite:

    @property
    def urls(self):
        return self.get_urls(), 'wiki', self.name
        return self.get_urls(), "wiki", self.name

    def get_root_urls(self):
        urlpatterns = [
            re_path(r'^$', self.article_view, name='root', kwargs={'path': ''}),
            re_path(r'^create-root/$', self.root_view, name='root_create'),
            re_path(r'^missing-root/$', self.root_missing_view, name='root_missing'),
            re_path(r'^_search/$', self.search_view, name='search'),
            re_path(r'^_revision/diff/(?P<revision_id>[0-9]+)/$', self.article_diff_view, name='diff'),
            re_path(r"^$", self.article_view, name="root", kwargs={"path": ""}),
            re_path(r"^create-root/$", self.root_view, name="root_create"),
            re_path(r"^missing-root/$", self.root_missing_view, name="root_missing"),
            re_path(r"^_search/$", self.search_view, name="search"),
            re_path(
                r"^_revision/diff/(?P<revision_id>[0-9]+)/$",
                self.article_diff_view,
                name="diff",
            ),
        ]
        return urlpatterns

    def get_deleted_list_urls(self):
        urlpatterns = [
            re_path('^_admin/$', self.deleted_list_view, name="deleted_list"),
            re_path("^_admin/$", self.deleted_list_view, name="deleted_list"),
        ]
        return urlpatterns

    def get_accounts_urls(self):
        if settings.ACCOUNT_HANDLING:
            urlpatterns = [
                re_path(r'^_accounts/sign-up/$', self.signup_view, name='signup'),
                re_path(r'^_accounts/logout/$', self.logout_view, name='logout'),
                re_path(r'^_accounts/login/$', self.login_view, name='login'),
                re_path(r'^_accounts/settings/$', self.profile_update_view, name='profile_update'),
                re_path(r"^_accounts/sign-up/$", self.signup_view, name="signup"),
                re_path(r"^_accounts/logout/$", self.logout_view, name="logout"),
                re_path(r"^_accounts/login/$", self.login_view, name="login"),
                re_path(
                    r"^_accounts/settings/$",
                    self.profile_update_view,
                    name="profile_update",
                ),
            ]
        else:
            urlpatterns = []


@@ 103,78 145,128 @@ class WikiSite:
        urlpatterns = [
            # This one doesn't work because it don't know
            # where to redirect after...
            re_path(r'^change/(?P<revision_id>[0-9]+)/$', self.revision_change_view, name='change_revision'),
            re_path(r'^preview/$', self.article_preview_view, name='preview_revision'),
            re_path(r'^merge/(?P<revision_id>[0-9]+)/preview/$', self.revision_preview_merge_view, name='merge_revision_preview'),
            re_path(
                r"^change/(?P<revision_id>[0-9]+)/$",
                self.revision_change_view,
                name="change_revision",
            ),
            re_path(r"^preview/$", self.article_preview_view, name="preview_revision"),
            re_path(
                r"^merge/(?P<revision_id>[0-9]+)/preview/$",
                self.revision_preview_merge_view,
                name="merge_revision_preview",
            ),
        ]
        return [
            re_path(r'^_revision/(?P<article_id>[0-9]+)/', include(urlpatterns)),
            re_path(r"^_revision/(?P<article_id>[0-9]+)/", include(urlpatterns)),
        ]

    def get_article_urls(self):
        urlpatterns = [
            # Paths decided by article_ids
            re_path(r'^$', self.article_view, name='get'),
            re_path(r'^delete/$', self.article_delete_view, name='delete'),
            re_path(r'^deleted/$', self.article_deleted_view, name='deleted'),
            re_path(r'^edit/$', self.article_edit_view, name='edit'),
            re_path(r'^move/$', self.article_move_view, name='move'),
            re_path(r'^preview/$', self.article_preview_view, name='preview'),
            re_path(r'^history/$', self.article_history_view, name='history'),
            re_path(r'^settings/$', self.article_settings_view, name='settings'),
            re_path(r'^source/$', self.article_source_view, name='source'),
            re_path(r'^revision/change/(?P<revision_id>[0-9]+)/$', self.revision_change_view, name='change_revision'),
            re_path(r'^revision/merge/(?P<revision_id>[0-9]+)/$', self.revision_merge_view, name='merge_revision'),
            re_path(r'^plugin/(?P<slug>\w+)/$', self.article_plugin_view, name='plugin'),
            re_path(r"^$", self.article_view, name="get"),
            re_path(r"^delete/$", self.article_delete_view, name="delete"),
            re_path(r"^deleted/$", self.article_deleted_view, name="deleted"),
            re_path(r"^edit/$", self.article_edit_view, name="edit"),
            re_path(r"^move/$", self.article_move_view, name="move"),
            re_path(r"^preview/$", self.article_preview_view, name="preview"),
            re_path(r"^history/$", self.article_history_view, name="history"),
            re_path(r"^settings/$", self.article_settings_view, name="settings"),
            re_path(r"^source/$", self.article_source_view, name="source"),
            re_path(
                r"^revision/change/(?P<revision_id>[0-9]+)/$",
                self.revision_change_view,
                name="change_revision",
            ),
            re_path(
                r"^revision/merge/(?P<revision_id>[0-9]+)/$",
                self.revision_merge_view,
                name="merge_revision",
            ),
            re_path(
                r"^plugin/(?P<slug>\w+)/$", self.article_plugin_view, name="plugin"
            ),
        ]
        return [
            re_path(r'^(?P<article_id>[0-9]+)/', include(urlpatterns)),
            re_path(r"^(?P<article_id>[0-9]+)/", include(urlpatterns)),
        ]

    def get_article_path_urls(self):
        urlpatterns = [
            # Paths decided by URLs
            re_path(r'^(?P<path>.+/|)_create/$', self.article_create_view, name='create'),
            re_path(r'^(?P<path>.+/|)_delete/$', self.article_delete_view, name='delete'),
            re_path(r'^(?P<path>.+/|)_deleted/$', self.article_deleted_view, name='deleted'),
            re_path(r'^(?P<path>.+/|)_edit/$', self.article_edit_view, name='edit'),
            re_path(r'^(?P<path>.+/|)_move/$', self.article_move_view, name='move'),
            re_path(r'^(?P<path>.+/|)_preview/$', self.article_preview_view, name='preview'),
            re_path(r'^(?P<path>.+/|)_history/$', self.article_history_view, name='history'),
            re_path(r'^(?P<path>.+/|)_dir/$', self.article_dir_view, name='dir'),
            re_path(r'^(?P<path>.+/|)_search/$', self.search_view, name='search'),
            re_path(r'^(?P<path>.+/|)_settings/$', self.article_settings_view, name='settings'),
            re_path(r'^(?P<path>.+/|)_source/$', self.article_source_view, name='source'),
            re_path(r'^(?P<path>.+/|)_revision/change/(?P<revision_id>[0-9]+)/$', self.revision_change_view, name='change_revision'),
            re_path(r'^(?P<path>.+/|)_revision/merge/(?P<revision_id>[0-9]+)/$', self.revision_merge_view, name='merge_revision'),
            re_path(r'^(?P<path>.+/|)_plugin/(?P<slug>\w+)/$', self.article_plugin_view, name='plugin'),
            re_path(
                r"^(?P<path>.+/|)_create/$", self.article_create_view, name="create"
            ),
            re_path(
                r"^(?P<path>.+/|)_delete/$", self.article_delete_view, name="delete"
            ),
            re_path(
                r"^(?P<path>.+/|)_deleted/$", self.article_deleted_view, name="deleted"
            ),
            re_path(r"^(?P<path>.+/|)_edit/$", self.article_edit_view, name="edit"),
            re_path(r"^(?P<path>.+/|)_move/$", self.article_move_view, name="move"),
            re_path(
                r"^(?P<path>.+/|)_preview/$", self.article_preview_view, name="preview"
            ),
            re_path(
                r"^(?P<path>.+/|)_history/$", self.article_history_view, name="history"
            ),
            re_path(r"^(?P<path>.+/|)_dir/$", self.article_dir_view, name="dir"),
            re_path(r"^(?P<path>.+/|)_search/$", self.search_view, name="search"),
            re_path(
                r"^(?P<path>.+/|)_settings/$",
                self.article_settings_view,
                name="settings",
            ),
            re_path(
                r"^(?P<path>.+/|)_source/$", self.article_source_view, name="source"
            ),
            re_path(
                r"^(?P<path>.+/|)_revision/change/(?P<revision_id>[0-9]+)/$",
                self.revision_change_view,
                name="change_revision",
            ),
            re_path(
                r"^(?P<path>.+/|)_revision/merge/(?P<revision_id>[0-9]+)/$",
                self.revision_merge_view,
                name="merge_revision",
            ),
            re_path(
                r"^(?P<path>.+/|)_plugin/(?P<slug>\w+)/$",
                self.article_plugin_view,
                name="plugin",
            ),
            # This should always go last!
            re_path(r'^(?P<path>.+/|)$', self.article_view, name='get'),
            re_path(r"^(?P<path>.+/|)$", self.article_view, name="get"),
        ]
        return urlpatterns

    def get_plugin_urls(self):
        urlpatterns = []
        for plugin in registry.get_plugins().values():
            slug = getattr(plugin, 'slug', None)
            slug = getattr(plugin, "slug", None)
            if slug:
                article_urlpatterns = plugin.urlpatterns.get('article', [])
                article_urlpatterns = plugin.urlpatterns.get("article", [])
                urlpatterns += [
                    re_path(r'^(?P<article_id>[0-9]+)/plugin/' + slug + '/',
                        include(article_urlpatterns)),
                    re_path(r'^(?P<path>.+/|)_plugin/' + slug + '/',
                        include(article_urlpatterns)),
                    re_path(
                        r"^(?P<article_id>[0-9]+)/plugin/" + slug + "/",
                        include(article_urlpatterns),
                    ),
                    re_path(
                        r"^(?P<path>.+/|)_plugin/" + slug + "/",
                        include(article_urlpatterns),
                    ),
                ]
                root_urlpatterns = plugin.urlpatterns.get('root', [])
                root_urlpatterns = plugin.urlpatterns.get("root", [])
                urlpatterns += [
                    re_path(r'^_plugin/' + slug + '/', include(root_urlpatterns)),
                    re_path(r"^_plugin/" + slug + "/", include(root_urlpatterns)),
                ]
        return urlpatterns


class DefaultWikiSite(LazyObject):
    def _setup(self):
        WikiSiteClass = import_string(apps.get_app_config('wiki').default_site)
        WikiSiteClass = import_string(apps.get_app_config("wiki").default_site)
        self._wrapped = WikiSiteClass()



M src/wiki/static/wiki/bootstrap/css/wiki-bootstrap.min.css => src/wiki/static/wiki/bootstrap/css/wiki-bootstrap.min.css +1 -1
@@ 10,4 10,4 @@
 *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,*:before,*:after{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000 !important}.label{border:1px solid #000}.table{border-collapse:collapse !important}.table td,.table th{background-color:#fff !important}.table-bordered th,.table-bordered td{border:1px solid #ddd !important}}@font-face{font-family:'Glyphicons Halflings';src:url('../fonts/glyphicons-halflings-regular.eot');src:url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'),url('../fonts/glyphicons-halflings-regular.woff') format('woff'),url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'),url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-euro:before,.glyphicon-eur:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:hover,a:focus{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive,.thumbnail>img,.thumbnail a>img,.carousel-inner>.item>img,.carousel-inner>.item>a>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small,.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 .small,h2 .small,h3 .small,h4 .small,h5 .small,h6 .small,.h1 .small,.h2 .small,.h3 .small,.h4 .small,.h5 .small,.h6 .small{font-weight:normal;line-height:1;color:#777}h1,.h1,h2,.h2,h3,.h3{margin-top:20px;margin-bottom:10px}h1 small,.h1 small,h2 small,.h2 small,h3 small,.h3 small,h1 .small,.h1 .small,h2 .small,.h2 .small,h3 .small,.h3 .small{font-size:65%}h4,.h4,h5,.h5,h6,.h6{margin-top:10px;margin-bottom:10px}h4 small,.h4 small,h5 small,.h5 small,h6 small,.h6 small,h4 .small,.h4 .small,h5 .small,.h5 .small,h6 .small,.h6 .small{font-size:75%}h1,.h1{font-size:36px}h2,.h2{font-size:30px}h3,.h3{font-size:24px}h4,.h4{font-size:18px}h5,.h5{font-size:14px}h6,.h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}small,.small{font-size:85%}mark,.mark{background-color:#fcf8e3;padding:.2em}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:hover,a.text-primary:focus{color:#286090}.text-success{color:#3c763d}a.text-success:hover,a.text-success:focus{color:#2b542c}.text-info{color:#31708f}a.text-info:hover,a.text-info:focus{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:hover,a.text-warning:focus{color:#66512c}.text-danger{color:#a94442}a.text-danger:hover,a.text-danger:focus{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:hover,a.bg-primary:focus{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:hover,a.bg-success:focus{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:hover,a.bg-info:focus{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover,a.bg-warning:focus{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover,a.bg-danger:focus{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:10px}ul ul,ol ul,ul ol,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dl{margin-top:0;margin-bottom:20px}dt,dd{line-height:1.42857143}dt{font-weight:bold}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote p:last-child,blockquote ul:last-child,blockquote ol:last-child{margin-bottom:0}blockquote footer,blockquote small,blockquote .small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote footer:before,blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.blockquote-reverse footer:before,blockquote.pull-right footer:before,.blockquote-reverse small:before,blockquote.pull-right small:before,.blockquote-reverse .small:before,blockquote.pull-right .small:before{content:''}.blockquote-reverse footer:after,blockquote.pull-right footer:after,.blockquote-reverse small:after,blockquote.pull-right small:after,.blockquote-reverse .small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}kbd kbd{padding:0;font-size:100%;font-weight:bold;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.row{margin-left:-15px;margin-right:-15px}.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>tbody>tr>td.active,.table>tfoot>tr>td.active,.table>thead>tr>th.active,.table>tbody>tr>th.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>tbody>tr.active>td,.table>tfoot>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr.active>th,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>tbody>tr>td.success,.table>tfoot>tr>td.success,.table>thead>tr>th.success,.table>tbody>tr>th.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>tbody>tr.success>td,.table>tfoot>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr.success>th,.table>tfoot>tr.success>th{background-color:#dff0d8}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d0e9c6}.table>thead>tr>td.info,.table>tbody>tr>td.info,.table>tfoot>tr>td.info,.table>thead>tr>th.info,.table>tbody>tr>th.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>tbody>tr.info>td,.table>tfoot>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr.info>th,.table>tfoot>tr.info>th{background-color:#d9edf7}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#c4e3f3}.table>thead>tr>td.warning,.table>tbody>tr>td.warning,.table>tfoot>tr>td.warning,.table>thead>tr>th.warning,.table>tbody>tr>th.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>tbody>tr.warning>td,.table>tfoot>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr.warning>th,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>tbody>tr>td.danger,.table>tfoot>tr>td.danger,.table>thead>tr>th.danger,.table>tbody>tr>th.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>tbody>tr.danger>td,.table>tfoot>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr.danger>th,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0;min-width:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}input[type="range"]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{border:0;background-color:transparent}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type="search"]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{line-height:34px}input[type="date"].input-sm,input[type="time"].input-sm,input[type="datetime-local"].input-sm,input[type="month"].input-sm,.input-group-sm input[type="date"],.input-group-sm input[type="time"],.input-group-sm input[type="datetime-local"],.input-group-sm input[type="month"]{line-height:30px}input[type="date"].input-lg,input[type="time"].input-lg,input[type="datetime-local"].input-lg,input[type="month"].input-lg,.input-group-lg input[type="date"],.input-group-lg input[type="time"],.input-group-lg input[type="datetime-local"],.input-group-lg input[type="month"]{line-height:46px}}.form-group{margin-bottom:15px}.radio,.checkbox{position:relative;display:block;margin-top:10px;margin-bottom:10px}.radio label,.checkbox label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{position:absolute;margin-left:-20px;margin-top:4px \9}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;vertical-align:middle;font-weight:normal;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="checkbox"][disabled],input[type="radio"].disabled,input[type="checkbox"].disabled,fieldset[disabled] input[type="radio"],fieldset[disabled] input[type="checkbox"]{cursor:not-allowed}.radio-inline.disabled,.checkbox-inline.disabled,fieldset[disabled] .radio-inline,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.radio.disabled label,.checkbox.disabled label,fieldset[disabled] .radio label,fieldset[disabled] .checkbox label{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:34px}.form-control-static.input-lg,.form-control-static.input-sm{padding-left:0;padding-right:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}textarea.input-sm,select[multiple].input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm textarea.form-control,.form-group-sm select[multiple].form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}textarea.input-lg,select[multiple].input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg textarea.form-control,.form-group-lg select[multiple].form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback,.input-group-lg+.form-control-feedback,.form-group-lg .form-control+.form-control-feedback{width:46px;height:46px;line-height:46px}.input-sm+.form-control-feedback,.input-group-sm+.form-control-feedback,.form-group-sm .form-control+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline,.has-success.radio label,.has-success.checkbox label,.has-success.radio-inline label,.has-success.checkbox-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;border-color:#3c763d;background-color:#dff0d8}.has-success .form-control-feedback{color:#3c763d}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline,.has-warning.radio label,.has-warning.checkbox label,.has-warning.radio-inline label,.has-warning.checkbox-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;border-color:#8a6d3b;background-color:#fcf8e3}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline,.has-error.radio label,.has-error.checkbox label,.has-error.radio-inline label,.has-error.checkbox-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;border-color:#a94442;background-color:#f2dede}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group .form-control{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .radio,.form-inline .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .radio label,.form-inline .checkbox label{padding-left:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .radio,.form-horizontal .checkbox{min-height:27px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}@media (min-width:768px){.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:normal;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.42857143;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn:focus,.btn:active:focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn.active.focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus,.btn.focus{color:#333;text-decoration:none}.btn:active,.btn.active{outline:0;background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:focus,.btn-default.focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default:active:hover,.btn-default.active:hover,.open>.dropdown-toggle.btn-default:hover,.btn-default:active:focus,.btn-default.active:focus,.open>.dropdown-toggle.btn-default:focus,.btn-default:active.focus,.btn-default.active.focus,.open>.dropdown-toggle.btn-default.focus{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default:active,.btn-default.active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled.focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default.focus{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary:active:hover,.btn-primary.active:hover,.open>.dropdown-toggle.btn-primary:hover,.btn-primary:active:focus,.btn-primary.active:focus,.open>.dropdown-toggle.btn-primary:focus,.btn-primary:active.focus,.btn-primary.active.focus,.open>.dropdown-toggle.btn-primary.focus{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary:active,.btn-primary.active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled.focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary.focus{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success:active:hover,.btn-success.active:hover,.open>.dropdown-toggle.btn-success:hover,.btn-success:active:focus,.btn-success.active:focus,.open>.dropdown-toggle.btn-success:focus,.btn-success:active.focus,.btn-success.active.focus,.open>.dropdown-toggle.btn-success.focus{color:#fff;background-color:#398439;border-color:#255625}.btn-success:active,.btn-success.active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled.focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success.focus{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info:active:hover,.btn-info.active:hover,.open>.dropdown-toggle.btn-info:hover,.btn-info:active:focus,.btn-info.active:focus,.open>.dropdown-toggle.btn-info:focus,.btn-info:active.focus,.btn-info.active.focus,.open>.dropdown-toggle.btn-info.focus{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info:active,.btn-info.active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled.focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info.focus{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning:active:hover,.btn-warning.active:hover,.open>.dropdown-toggle.btn-warning:hover,.btn-warning:active:focus,.btn-warning.active:focus,.open>.dropdown-toggle.btn-warning:focus,.btn-warning:active.focus,.btn-warning.active.focus,.open>.dropdown-toggle.btn-warning.focus{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning:active,.btn-warning.active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled.focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning.focus{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger:active:hover,.btn-danger.active:hover,.open>.dropdown-toggle.btn-danger:hover,.btn-danger:active:focus,.btn-danger.active:focus,.open>.dropdown-toggle.btn-danger:focus,.btn-danger:active.focus,.btn-danger.active.focus,.open>.dropdown-toggle.btn-danger.focus{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger:active,.btn-danger.active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled.focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger.focus{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#337ab7;font-weight:normal;border-radius:0}.btn-link,.btn-link:active,.btn-link.active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#777;text-decoration:none}.btn-lg,.btn-group-lg>.btn{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-sm,.btn-group-sm>.btn{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-xs,.btn-group-xs>.btn{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-property:height, visibility;transition-property:height, visibility;-webkit-transition-duration:.35s;transition-duration:.35s;-webkit-transition-timing-function:ease;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid \9;border-right:4px solid transparent;border-left:4px solid transparent}.dropup,.dropdown{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;font-size:14px;text-align:left;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,0.175);box-shadow:0 6px 12px rgba(0,0,0,0.175);background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{text-decoration:none;color:#262626;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{color:#fff;text-decoration:none;outline:0;background-color:#337ab7}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{color:#777}.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{text-decoration:none;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);cursor:not-allowed}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{left:auto;right:0}.dropdown-menu-left{left:0;right:auto}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;left:0;right:0;bottom:0;top:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid \9;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{left:auto;right:0}.navbar-right .dropdown-menu-left{left:0;right:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;float:left}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover,.btn-group>.btn:focus,.btn-group-vertical>.btn:focus,.btn-group>.btn:active,.btn-group-vertical>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn.active{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-left:8px;padding-right:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-left:12px;padding-right:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-top-left-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-right-radius:0;border-top-left-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{float:none;display:table-cell;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle="buttons"]>.btn input[type="radio"],[data-toggle="buttons"]>.btn-group>.btn input[type="radio"],[data-toggle="buttons"]>.btn input[type="checkbox"],[data-toggle="buttons"]>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*="col-"]{float:none;padding-left:0;padding-right:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn,select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn,select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn{height:auto}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:normal;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type="radio"],.input-group-addon input[type="checkbox"]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group-btn:last-child>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-top-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:first-child>.btn-group:not(:first-child)>.btn{border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:hover,.input-group-btn>.btn:focus,.input-group-btn>.btn:active{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{margin-bottom:0;padding-left:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:hover,.nav>li>a:focus{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:hover,.nav>li.disabled>a:focus{color:#777;text-decoration:none;background-color:transparent;cursor:not-allowed}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{color:#555;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:hover,.nav-pills>li.active>a:focus{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{overflow-x:visible;padding-right:15px;padding-left:15px;border-top:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);-webkit-overflow-scrolling:touch}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;box-shadow:none}.navbar-collapse.collapse{display:block !important;height:auto !important;padding-bottom:0;overflow:visible !important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{padding-left:0;padding-right:0}}.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{max-height:200px}}.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container>.navbar-header,.container-fluid>.navbar-header,.container>.navbar-collapse,.container-fluid>.navbar-collapse{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-top,.navbar-fixed-bottom{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:15px 15px;font-size:18px;line-height:20px;height:50px}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;margin-right:15px;padding:9px 10px;margin-top:8px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;box-shadow:none}.navbar-nav .open .dropdown-menu>li>a,.navbar-nav .open .dropdown-menu .dropdown-header{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:hover,.navbar-nav .open .dropdown-menu>li>a:focus{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{margin-left:-15px;margin-right:-15px;padding:10px 15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);margin-top:8px;margin-bottom:8px}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn,.navbar-form .input-group .form-control{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .radio,.navbar-form .checkbox{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .radio label,.navbar-form .checkbox label{padding-left:0}.navbar-form .radio input[type="radio"],.navbar-form .checkbox input[type="checkbox"]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;border:0;margin-left:0;margin-right:0;padding-top:0;padding-bottom:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-right-radius:4px;border-top-left-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-left:15px;margin-right:15px}}@media (min-width:768px){.navbar-left{float:left !important;float:left}.navbar-right{float:right !important;float:right;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:hover,.navbar-default .navbar-brand:focus{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:hover,.navbar-default .navbar-nav>.disabled>a:focus{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:hover,.navbar-default .navbar-toggle:focus{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{background-color:#e7e7e7;color:#555}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:hover,.navbar-default .btn-link:focus{color:#333}.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:hover,.navbar-default .btn-link[disabled]:focus,fieldset[disabled] .navbar-default .btn-link:focus{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:hover,.navbar-inverse .navbar-brand:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:hover,.navbar-inverse .navbar-nav>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:hover,.navbar-inverse .navbar-nav>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:hover,.navbar-inverse .navbar-nav>.disabled>a:focus{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:hover,.navbar-inverse .navbar-toggle:focus{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:hover,.navbar-inverse .navbar-nav>.open>a:focus{background-color:#080808;color:#fff}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:hover,.navbar-inverse .btn-link:focus{color:#fff}.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:hover,.navbar-inverse .btn-link[disabled]:focus,fieldset[disabled] .navbar-inverse .btn-link:focus{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{content:"/\00a0";padding:0 5px;color:#ccc}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;line-height:1.42857143;text-decoration:none;color:#337ab7;background-color:#fff;border:1px solid #ddd;margin-left:-1px}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-bottom-right-radius:4px;border-top-right-radius:4px}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>span,.pagination>.active>a:hover,.pagination>.active>span:hover,.pagination>.active>a:focus,.pagination>.active>span:focus{z-index:3;color:#fff;background-color:#337ab7;border-color:#337ab7;cursor:default}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#777;background-color:#fff;border-color:#ddd;cursor:not-allowed}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-bottom-right-radius:3px;border-top-right-radius:3px}.pager{padding-left:0;margin:20px 0;list-style:none;text-align:center}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:hover,.pager li>a:focus{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:#777;background-color:#fff;cursor:not-allowed}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:hover,a.label:focus{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:hover,.label-default[href]:focus{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:hover,.label-primary[href]:focus{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:hover,.label-success[href]:focus{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:hover,.label-info[href]:focus{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:hover,.label-warning[href]:focus{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:hover,.label-danger[href]:focus{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:bold;color:#fff;line-height:1;vertical-align:middle;white-space:nowrap;text-align:center;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-xs .badge,.btn-group-xs>.btn .badge{top:0;padding:1px 5px}a.badge:hover,a.badge:focus{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron h1,.jumbotron .h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px;padding-left:15px;padding-right:15px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-left:60px;padding-right:60px}.jumbotron h1,.jumbotron .h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail>img,.thumbnail a>img{margin-left:auto;margin-right:auto}a.thumbnail:hover,a.thumbnail:focus,a.thumbnail.active{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:bold}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{background-color:#dff0d8;border-color:#d6e9c6;color:#3c763d}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{background-color:#d9edf7;border-color:#bce8f1;color:#31708f}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{background-color:#fcf8e3;border-color:#faebcc;color:#8a6d3b}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{background-color:#f2dede;border-color:#ebccd1;color:#a94442}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{overflow:hidden;height:20px;margin-bottom:20px;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-striped .progress-bar,.progress-bar-striped{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-size:40px 40px}.progress.active .progress-bar,.progress-bar.active{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-left,.media-right,.media-body{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{margin-bottom:20px;padding-left:0}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:hover,button.list-group-item:hover,a.list-group-item:focus,button.list-group-item:focus{text-decoration:none;color:#555;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:hover,.list-group-item.disabled:focus{background-color:#eee;color:#777;cursor:not-allowed}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>.small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:hover .list-group-item-text,.list-group-item.active:focus .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:hover,button.list-group-item-success:hover,a.list-group-item-success:focus,button.list-group-item-success:focus{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,button.list-group-item-success.active,a.list-group-item-success.active:hover,button.list-group-item-success.active:hover,a.list-group-item-success.active:focus,button.list-group-item-success.active:focus{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:hover,button.list-group-item-info:hover,a.list-group-item-info:focus,button.list-group-item-info:focus{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,button.list-group-item-info.active,a.list-group-item-info.active:hover,button.list-group-item-info.active:hover,a.list-group-item-info.active:focus,button.list-group-item-info.active:focus{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:hover,button.list-group-item-warning:hover,a.list-group-item-warning:focus,button.list-group-item-warning:focus{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,button.list-group-item-warning.active,a.list-group-item-warning.active:hover,button.list-group-item-warning.active:hover,a.list-group-item-warning.active:focus,button.list-group-item-warning.active:focus{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:hover,button.list-group-item-danger:hover,a.list-group-item-danger:focus,button.list-group-item-danger:focus{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,button.list-group-item-danger.active,a.list-group-item-danger.active:hover,button.list-group-item-danger.active:hover,a.list-group-item-danger.active:focus,button.list-group-item-danger.active:focus{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.05);box-shadow:0 1px 1px rgba(0,0,0,0.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>a,.panel-title>small,.panel-title>.small,.panel-title>small>a,.panel-title>.small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-right-radius:3px;border-top-left-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.table,.panel>.table-responsive>.table,.panel>.panel-collapse>.table{margin-bottom:0}.panel>.table caption,.panel>.table-responsive>.table caption,.panel>.panel-collapse>.table caption{padding-left:15px;padding-right:15px}.panel>.table:first-child,.panel>.table-responsive:first-child>.table:first-child{border-top-right-radius:3px;border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table:last-child,.panel>.table-responsive:last-child>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-left-radius:3px;border-bottom-right-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child th,.panel>.table>tbody:first-child>tr:first-child td{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{border:0;margin-bottom:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.panel-body,.panel-group .panel-heading+.panel-collapse>.list-group{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;left:0;bottom:0;height:100%;width:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.05);box-shadow:inset 0 1px 1px rgba(0,0,0,0.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,0.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:bold;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}.close:hover,.close:focus{color:#000;text-decoration:none;cursor:pointer;opacity:.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{display:none;overflow:hidden;position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translate(0, -25%);-ms-transform:translate(0, -25%);-o-transform:translate(0, -25%);transform:translate(0, -25%);-webkit-transition:-webkit-transform 0.3s ease-out;-moz-transition:-moz-transform 0.3s ease-out;-o-transition:-o-transform 0.3s ease-out;transition:transform 0.3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0, 0);-ms-transform:translate(0, 0);-o-transform:translate(0, 0);transform:translate(0, 0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);box-shadow:0 3px 9px rgba(0,0,0,0.5);background-clip:padding-box;outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-left:5px;margin-bottom:0}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.5);box-shadow:0 5px 15px rgba(0,0,0,0.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:12px;opacity:0;filter:alpha(opacity=0)}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{margin-top:-3px;padding:5px 0}.tooltip.right{margin-left:3px;padding:0 5px}.tooltip.bottom{margin-top:3px;padding:5px 0}.tooltip.left{margin-left:-3px;padding:0 5px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{bottom:0;right:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:normal;letter-spacing:normal;line-break:auto;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;font-size:14px;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{margin:0;padding:8px 14px;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{border-width:10px;content:""}.popover.top>.arrow{left:50%;margin-left:-11px;border-bottom-width:0;border-top-color:#999;border-top-color:rgba(0,0,0,0.25);bottom:-11px}.popover.top>.arrow:after{content:" ";bottom:1px;margin-left:-10px;border-bottom-width:0;border-top-color:#fff}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-left-width:0;border-right-color:#999;border-right-color:rgba(0,0,0,0.25)}.popover.right>.arrow:after{content:" ";left:1px;bottom:-10px;border-left-width:0;border-right-color:#fff}.popover.bottom>.arrow{left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,0.25);top:-11px}.popover.bottom>.arrow:after{content:" ";top:1px;margin-left:-10px;border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,0.25)}.popover.left>.arrow:after{content:" ";right:1px;border-right-width:0;border-left-color:#fff;bottom:-10px}.carousel{position:relative}.carousel-inner{position:relative;overflow:hidden;width:100%}.carousel-inner>.item{display:none;position:relative;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>img,.carousel-inner>.item>a>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform 0.6s ease-in-out;-moz-transition:-moz-transform 0.6s ease-in-out;-o-transition:-o-transform 0.6s ease-in-out;transition:transform 0.6s ease-in-out;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;-moz-perspective:1000px;perspective:1000px}.carousel-inner>.item.next,.carousel-inner>.item.active.right{-webkit-transform:translate3d(100%, 0, 0);transform:translate3d(100%, 0, 0);left:0}.carousel-inner>.item.prev,.carousel-inner>.item.active.left{-webkit-transform:translate3d(-100%, 0, 0);transform:translate3d(-100%, 0, 0);left:0}.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right,.carousel-inner>.item.active{-webkit-transform:translate3d(0, 0, 0);transform:translate3d(0, 0, 0);left:0}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;left:0;bottom:0;width:15%;opacity:.5;filter:alpha(opacity=50);font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6);background-color:rgba(0,0,0,0)}.carousel-control.left{background-image:-webkit-linear-gradient(left, rgba(0,0,0,0.5) 0, rgba(0,0,0,0.0001) 100%);background-image:-o-linear-gradient(left, rgba(0,0,0,0.5) 0, rgba(0,0,0,0.0001) 100%);background-image:linear-gradient(to right, rgba(0,0,0,0.5) 0, rgba(0,0,0,0.0001) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1)}.carousel-control.right{left:auto;right:0;background-image:-webkit-linear-gradient(left, rgba(0,0,0,0.0001) 0, rgba(0,0,0,0.5) 100%);background-image:-o-linear-gradient(left, rgba(0,0,0,0.0001) 0, rgba(0,0,0,0.5) 100%);background-image:linear-gradient(to right, rgba(0,0,0,0.0001) 0, rgba(0,0,0,0.5) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1)}.carousel-control:hover,.carousel-control:focus{outline:0;color:#fff;text-decoration:none;opacity:.9;filter:alpha(opacity=90)}.carousel-control .icon-prev,.carousel-control .icon-next,.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right{position:absolute;top:50%;margin-top:-10px;z-index:5;display:inline-block}.carousel-control .icon-prev,.carousel-control .glyphicon-chevron-left{left:50%;margin-left:-10px}.carousel-control .icon-next,.carousel-control .glyphicon-chevron-right{right:50%;margin-right:-10px}.carousel-control .icon-prev,.carousel-control .icon-next{width:20px;height:20px;line-height:1;font-family:serif}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;margin-left:-30%;padding-left:0;list-style:none;text-align:center}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;border:1px solid #fff;border-radius:10px;cursor:pointer;background-color:#000 \9;background-color:rgba(0,0,0,0)}.carousel-indicators .active{margin:0;width:12px;height:12px;background-color:#fff}.carousel-caption{position:absolute;left:15%;right:15%;bottom:20px;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-prev,.carousel-control .icon-next{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{left:20%;right:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:before,.clearfix:after,.dl-horizontal dd:before,.dl-horizontal dd:after,.container:before,.container:after,.container-fluid:before,.container-fluid:after,.row:before,.row:after,.form-horizontal .form-group:before,.form-horizontal .form-group:after,.btn-toolbar:before,.btn-toolbar:after,.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after,.nav:before,.nav:after,.navbar:before,.navbar:after,.navbar-header:before,.navbar-header:after,.navbar-collapse:before,.navbar-collapse:after,.pager:before,.pager:after,.panel-body:before,.panel-body:after,.modal-header:before,.modal-header:after,.modal-footer:before,.modal-footer:after,.form-actions:before,.form-actions:after{content:" ";display:table}.clearfix:after,.dl-horizontal dd:after,.container:after,.container-fluid:after,.row:after,.form-horizontal .form-group:after,.btn-toolbar:after,.btn-group-vertical>.btn-group:after,.nav:after,.navbar:after,.navbar-header:after,.navbar-collapse:after,.pager:after,.panel-body:after,.modal-header:after,.modal-footer:after,.form-actions:after{clear:both}.center-block{display:block;margin-left:auto;margin-right:auto}.pull-right{float:right !important}.pull-left{float:left !important}.hide{display:none !important}.show{display:block !important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none !important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-xs,.visible-sm,.visible-md,.visible-lg{display:none !important}.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block{display:none !important}@media (max-width:767px){.visible-xs{display:block !important}table.visible-xs{display:table !important}tr.visible-xs{display:table-row !important}th.visible-xs,td.visible-xs{display:table-cell !important}}@media (max-width:767px){.visible-xs-block{display:block !important}}@media (max-width:767px){.visible-xs-inline{display:inline !important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block !important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block !important}table.visible-sm{display:table !important}tr.visible-sm{display:table-row !important}th.visible-sm,td.visible-sm{display:table-cell !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline !important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block !important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block !important}table.visible-md{display:table !important}tr.visible-md{display:table-row !important}th.visible-md,td.visible-md{display:table-cell !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline !important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block !important}}@media (min-width:1200px){.visible-lg{display:block !important}table.visible-lg{display:table !important}tr.visible-lg{display:table-row !important}th.visible-lg,td.visible-lg{display:table-cell !important}}@media (min-width:1200px){.visible-lg-block{display:block !important}}@media (min-width:1200px){.visible-lg-inline{display:inline !important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block !important}}@media (max-width:767px){.hidden-xs{display:none !important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none !important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none !important}}@media (min-width:1200px){.hidden-lg{display:none !important}}.visible-print{display:none !important}@media print{.visible-print{display:block !important}table.visible-print{display:table !important}tr.visible-print{display:table-row !important}th.visible-print,td.visible-print{display:table-cell !important}}.visible-print-block{display:none !important}@media print{.visible-print-block{display:block !important}}.visible-print-inline{display:none !important}@media print{.visible-print-inline{display:inline !important}}.visible-print-inline-block{display:none !important}@media print{.visible-print-inline-block{display:inline-block !important}}@media print{.hidden-print{display:none !important}}.wiki-article .thumbnail{clear:both;margin-bottom:15px;margin-left:10px;margin-right:10px}#div_id_title .asteriskField{display:none}#id_title{font-size:20px;height:40px;padding:6px;display:block;width:98%}#id_summary{width:98%;padding:6px}#id_content{width:100%;padding:6px}h1#article-title{font-size:2.5em;margin-top:0}.article-edit-title-link{font-size:14px;padding-left:8px}.wiki-label label{font-size:16px;font-weight:normal;color:#777}.controls ul{margin-left:0;list-style:none}#attachment_form #id_description{width:95%}.wiki-article div.toc,.wiki-article div.article-list{max-width:340px;clear:left;display:inline-block}.wiki-article div.toc .nav-header,.wiki-article div.article-list .nav-header{padding:3px 10px;border-bottom:1px solid #ddd;font-weight:bold}.wiki-article div.toc ul,.wiki-article div.article-list ul{padding-left:0;list-style:none}.wiki-article div.toc>ul,.wiki-article div.article-list>ul{border-radius:5px;background-color:#f6f6f6;padding-top:5px}.wiki-article div.toc ul li ul li,.wiki-article div.article-list ul li ul li{padding-left:10px}.wiki-article div.toc ul li a,.wiki-article div.article-list ul li a{padding:5px 10px;display:block;border-bottom:1px solid #ddd}.wiki-article div.toc ul li:last-child,.wiki-article div.article-list ul li:last-child{margin-bottom:7px}.wiki-article div.toc .toctitle{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.wiki-article a.linknotfound{color:#c87}.wiki-broken{color:#ba0000}.wiki-article pre{max-width:700px;max-height:150px;overflow:auto;word-wrap:normal;white-space:pre}.wiki-article .codehilitetable{max-width:700px;max-height:150px;background-color:#f5f5f5;border-color:#ccc;border-radius:4px;border-collapse:separate;display:block;overflow:auto}.wiki-article .codehilitetable td:first-child{border-left:none;border-top:none;border-bottom:none}.wiki-article .codehilitetable td:last-child{border:none}.wiki-article .codehilitetable td{padding:0}.wiki-article .codehilitetable pre{margin-bottom:0}.wiki-article table{width:100%;max-width:100%;margin-bottom:20px;border:1px solid #ddd}.wiki-article table>thead>tr>th,.wiki-article table>tbody>tr>th,.wiki-article table>tfoot>tr>th,.wiki-article table>thead>tr>td,.wiki-article table>tbody>tr>td,.wiki-article table>tfoot>tr>td{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.wiki-article table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.wiki-article table>caption+thead>tr:first-child>th,.wiki-article table>colgroup+thead>tr:first-child>th,.wiki-article table>thead:first-child>tr:first-child>th,.wiki-article table>caption+thead>tr:first-child>td,.wiki-article table>colgroup+thead>tr:first-child>td,.wiki-article table>thead:first-child>tr:first-child>td{border-top:0}.wiki-article table>tbody+tbody{border-top:2px solid #ddd}.wiki-article table .table{background-color:#fff}.wiki-article table>thead>tr>th,.wiki-article table>tbody>tr>th,.wiki-article table>tfoot>tr>th,.wiki-article table>thead>tr>td,.wiki-article table>tbody>tr>td,.wiki-article table>tfoot>tr>td{border:1px solid #ddd}.wiki-article table>thead>tr>th,.wiki-article table>thead>tr>td{border-bottom-width:2px}.table-responsive{border:none}.wiki-article h1,.wiki-article h2,.wiki-article h3,.wiki-article h4,.wiki-article h5,.wiki-article h6{overflow:hidden;margin-right:5px}.wiki-article h1{font-size:28px;padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee;clear:both}.wiki-article h2{font-size:24.5px;padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee;margin-bottom:20px;padding-bottom:0}.wiki-article h3{font-size:21px;margin:15px 0 10px;line-height:30px}.wiki-article h4{font-size:17.5px}.wiki-article h5,.wiki-article h6{font-size:14px}.wiki-article blockquote p,.wiki-article blockquote{font-size:14px}input[type=file]{float:none;width:auto}.asteriskField{font-size:20px;margin-left:5px}.notification-li .since{font-size:80%;color:#ccc}.directory-toolbar .filter-clear{margin-right:10px;position:relative;top:5px}.panel-heading h3{margin:0}.diff-container{overflow-x:scroll}.breadcrumb .icon-bar{display:block;width:18px;height:2px;background-color:#f5f5f5;border-radius:1px 1px 1px 1px;box-shadow:0 1px 0 rgba(0,0,0,0.25);margin-top:3px}.breadcrumb .icon-bar:first-child{margin-top:0}#article-menu{border-bottom:1px solid #eee;padding-bottom:0;margin-bottom:20px}#article-container{margin-top:20px}#wiki-footer{padding:30px 0;clear:both}.wiki-modal .modal-body iframe{width:100%;min-height:400px;height:100%;border:0}.ui-resizable-s{bottom:0}.ui-resizable-e{right:0}.ui-resizable{position:fixed !important}@media print{.navbar,.nav-tabs li a,#article-breadcrumbs{display:none}#article-menu li{display:none}#article-title-li{display:block !important}}.wiki-form-block input[type='text'],.wiki-form-block select{width:auto;display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s}.wiki-form-block input[type='text']:focus,.wiki-form-block select:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6)}.wiki-form-block input[type='text']::-moz-placeholder,.wiki-form-block select::-moz-placeholder{color:#999;opacity:1}.wiki-form-block input[type='text']:-ms-input-placeholder,.wiki-form-block select:-ms-input-placeholder{color:#999}.wiki-form-block input[type='text']::-webkit-input-placeholder,.wiki-form-block select::-webkit-input-placeholder{color:#999}.wiki-form-block input[type='text']::-ms-expand,.wiki-form-block select::-ms-expand{border:0;background-color:transparent}.wiki-form-block input[type='text'][disabled],.wiki-form-block select[disabled],.wiki-form-block input[type='text'][readonly],.wiki-form-block select[readonly],fieldset[disabled] .wiki-form-block input[type='text'],fieldset[disabled] .wiki-form-block select{background-color:#eee;opacity:1}.wiki-form-block input[type='text'][disabled],.wiki-form-block select[disabled],fieldset[disabled] .wiki-form-block input[type='text'],fieldset[disabled] .wiki-form-block select{cursor:not-allowed}textarea.wiki-form-block input[type='text'],textarea.wiki-form-block select{height:auto}.wiki-control input[type=text],.wiki-control input[type=email],.wiki-control input[type=password]{height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;vertical-align:middle;width:280px;display:inline-block}.form-horizontal .wiki-control input[type='text'],.form-horizontal .wiki-control input[type='password'],.form-horizontal .wiki-control input[type='email'],.form-horizontal .wiki-control select,.form-horizontal .wiki-control textarea{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;width:280px;display:inline-block}.form-horizontal .wiki-control input[type='text']:focus,.form-horizontal .wiki-control input[type='password']:focus,.form-horizontal .wiki-control input[type='email']:focus,.form-horizontal .wiki-control select:focus,.form-horizontal .wiki-control textarea:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6)}.form-horizontal .wiki-control input[type='text']::-moz-placeholder,.form-horizontal .wiki-control input[type='password']::-moz-placeholder,.form-horizontal .wiki-control input[type='email']::-moz-placeholder,.form-horizontal .wiki-control select::-moz-placeholder,.form-horizontal .wiki-control textarea::-moz-placeholder{color:#999;opacity:1}.form-horizontal .wiki-control input[type='text']:-ms-input-placeholder,.form-horizontal .wiki-control input[type='password']:-ms-input-placeholder,.form-horizontal .wiki-control input[type='email']:-ms-input-placeholder,.form-horizontal .wiki-control select:-ms-input-placeholder,.form-horizontal .wiki-control textarea:-ms-input-placeholder{color:#999}.form-horizontal .wiki-control input[type='text']::-webkit-input-placeholder,.form-horizontal .wiki-control input[type='password']::-webkit-input-placeholder,.form-horizontal .wiki-control input[type='email']::-webkit-input-placeholder,.form-horizontal .wiki-control select::-webkit-input-placeholder,.form-horizontal .wiki-control textarea::-webkit-input-placeholder{color:#999}.form-horizontal .wiki-control input[type='text']::-ms-expand,.form-horizontal .wiki-control input[type='password']::-ms-expand,.form-horizontal .wiki-control input[type='email']::-ms-expand,.form-horizontal .wiki-control select::-ms-expand,.form-horizontal .wiki-control textarea::-ms-expand{border:0;background-color:transparent}.form-horizontal .wiki-control input[type='text'][disabled],.form-horizontal .wiki-control input[type='password'][disabled],.form-horizontal .wiki-control input[type='email'][disabled],.form-horizontal .wiki-control select[disabled],.form-horizontal .wiki-control textarea[disabled],.form-horizontal .wiki-control input[type='text'][readonly],.form-horizontal .wiki-control input[type='password'][readonly],.form-horizontal .wiki-control input[type='email'][readonly],.form-horizontal .wiki-control select[readonly],.form-horizontal .wiki-control textarea[readonly],fieldset[disabled] .form-horizontal .wiki-control input[type='text'],fieldset[disabled] .form-horizontal .wiki-control input[type='password'],fieldset[disabled] .form-horizontal .wiki-control input[type='email'],fieldset[disabled] .form-horizontal .wiki-control select,fieldset[disabled] .form-horizontal .wiki-control textarea{background-color:#eee;opacity:1}.form-horizontal .wiki-control input[type='text'][disabled],.form-horizontal .wiki-control input[type='password'][disabled],.form-horizontal .wiki-control input[type='email'][disabled],.form-horizontal .wiki-control select[disabled],.form-horizontal .wiki-control textarea[disabled],fieldset[disabled] .form-horizontal .wiki-control input[type='text'],fieldset[disabled] .form-horizontal .wiki-control input[type='password'],fieldset[disabled] .form-horizontal .wiki-control input[type='email'],fieldset[disabled] .form-horizontal .wiki-control select,fieldset[disabled] .form-horizontal .wiki-control textarea{cursor:not-allowed}textarea.form-horizontal .wiki-control input[type='text'],textarea.form-horizontal .wiki-control input[type='password'],textarea.form-horizontal .wiki-control input[type='email'],textarea.form-horizontal .wiki-control select,textarea.form-horizontal .wiki-control textarea{height:auto}.form-horizontal .wiki-control input[type=text],.form-horizontal .wiki-control input[type=password]{width:280px;display:inline-block}.form-horizontal .wiki-control .input-group{width:280px;display:inline-table}.form-vertical .wiki-control textarea,.form-horizontal .wiki-control textarea{height:200px}.form-vertical .wiki-control input[type='text'],.form-vertical .wiki-control select,.form-vertical .wiki-control textarea{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s, box-shadow ease-in-out .15s;width:95%;display:inline-block}.form-vertical .wiki-control input[type='text']:focus,.form-vertical .wiki-control select:focus,.form-vertical .wiki-control textarea:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6)}.form-vertical .wiki-control input[type='text']::-moz-placeholder,.form-vertical .wiki-control select::-moz-placeholder,.form-vertical .wiki-control textarea::-moz-placeholder{color:#999;opacity:1}.form-vertical .wiki-control input[type='text']:-ms-input-placeholder,.form-vertical .wiki-control select:-ms-input-placeholder,.form-vertical .wiki-control textarea:-ms-input-placeholder{color:#999}.form-vertical .wiki-control input[type='text']::-webkit-input-placeholder,.form-vertical .wiki-control select::-webkit-input-placeholder,.form-vertical .wiki-control textarea::-webkit-input-placeholder{color:#999}.form-vertical .wiki-control input[type='text']::-ms-expand,.form-vertical .wiki-control select::-ms-expand,.form-vertical .wiki-control textarea::-ms-expand{border:0;background-color:transparent}.form-vertical .wiki-control input[type='text'][disabled],.form-vertical .wiki-control select[disabled],.form-vertical .wiki-control textarea[disabled],.form-vertical .wiki-control input[type='text'][readonly],.form-vertical .wiki-control select[readonly],.form-vertical .wiki-control textarea[readonly],fieldset[disabled] .form-vertical .wiki-control input[type='text'],fieldset[disabled] .form-vertical .wiki-control select,fieldset[disabled] .form-vertical .wiki-control textarea{background-color:#eee;opacity:1}.form-vertical .wiki-control input[type='text'][disabled],.form-vertical .wiki-control select[disabled],.form-vertical .wiki-control textarea[disabled],fieldset[disabled] .form-vertical .wiki-control input[type='text'],fieldset[disabled] .form-vertical .wiki-control select,fieldset[disabled] .form-vertical .wiki-control textarea{cursor:not-allowed}textarea.form-vertical .wiki-control input[type='text'],textarea.form-vertical .wiki-control select,textarea.form-vertical .wiki-control textarea{height:auto}.form-vertical .wiki-control,.form-vertical .wiki-label{display:block;float:none;padding:0;margin:0;width:100%}.form-actions{margin-left:-15px;margin-right:-15px;padding:19px 15px 20px;padding-left:0;margin-top:20px;margin-bottom:20px;background-color:#f5f5f5;border-top:1px solid #e5e5e5;float:none}.panel-heading .icon{color:#444;font-size:75%}.wiki-control select[multiple],.wiki-control select[size]{height:auto}.wiki-modal .modal-content{width:100%}@media (min-width:980px){.wiki-modal .modal-dialog{width:80%}}@media (max-width:979px){.wiki-modal .modal-dialog{width:90%}}@media (max-width:767px){.wiki-modal .modal-dialog{width:90%}}@media screen and (min-width:768px){.pull-right-block-on-responsive{float:right !important;float:right}#wiki-search-form{width:170px}}/*!
 *  Font Awesome 4.2.0 by @davegandy - http://fontawesome.io - @fontawesome
 *  License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
 */@font-face{font-family:'FontAwesome';src:url('../../font-awesome/font/fontawesome-webfont.eot?v=4.2.0');src:url('../../font-awesome/font/fontawesome-webfont.eot?#iefix&v=4.2.0') format('embedded-opentype'),url('../../font-awesome/font/fontawesome-webfont.woff?v=4.2.0') format('woff'),url('../../font-awesome/font/fontawesome-webfont.ttf?v=4.2.0') format('truetype'),url('../../font-awesome/font/fontawesome-webfont.svg?v=4.2.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.wiki-article .codehilite-wrap{overflow:auto;max-width:auto;max-height:250px}.wiki-article .codehilitetable pre{max-width:none;max-height:none;border:0;background-color:transparent}.wiki-article .codehilitetable .linenos{width:1%;white-space:nowrap}.codehilite .hll{background-color:#ffc}.codehilite .c{color:#808080}.codehilite .err{color:#f00000;background-color:#f0a0a0}.codehilite .k{color:#008000;font-weight:bold}.codehilite .o{color:#303030}.codehilite .cm{color:#808080}.codehilite .cp{color:#507090}.codehilite .c1{color:#808080}.codehilite .cs{color:#c00;font-weight:bold}.codehilite .gd{color:#a00000}.codehilite .ge{font-style:italic}.codehilite .gr{color:#f00}.codehilite .gh{color:#000080;font-weight:bold}.codehilite .gi{color:#00a000}.codehilite .go{color:#808080}.codehilite .gp{color:#c65d09;font-weight:bold}.codehilite .gs{font-weight:bold}.codehilite .gu{color:#800080;font-weight:bold}.codehilite .gt{color:#0040d0}.codehilite .kc{color:#008000;font-weight:bold}.codehilite .kd{color:#008000;font-weight:bold}.codehilite .kn{color:#008000;font-weight:bold}.codehilite .kp{color:#003080;font-weight:bold}.codehilite .kr{color:#008000;font-weight:bold}.codehilite .kt{color:#303090;font-weight:bold}.codehilite .m{color:#6000e0;font-weight:bold}.codehilite .s{background-color:#fff0f0}.codehilite .na{color:#0000c0}.codehilite .nb{color:#007020}.codehilite .nc{color:#b00060;font-weight:bold}.codehilite .no{color:#003060;font-weight:bold}.codehilite .nd{color:#505050;font-weight:bold}.codehilite .ni{color:#800000;font-weight:bold}.codehilite .ne{color:#f00000;font-weight:bold}.codehilite .nf{color:#0060b0;font-weight:bold}.codehilite .nl{color:#907000;font-weight:bold}.codehilite .nn{color:#0e84b5;font-weight:bold}.codehilite .nt{color:#007000}.codehilite .nv{color:#906030}.codehilite .ow{color:#000;font-weight:bold}.codehilite .w{color:#bbb}.codehilite .mf{color:#6000e0;font-weight:bold}.codehilite .mh{color:#005080;font-weight:bold}.codehilite .mi{color:#0000d0;font-weight:bold}.codehilite .mo{color:#4000e0;font-weight:bold}.codehilite .sb{background-color:#fff0f0}.codehilite .sc{color:#0040d0}.codehilite .sd{color:#d04020}.codehilite .s2{background-color:#fff0f0}.codehilite .se{color:#606060;font-weight:bold;background-color:#fff0f0}.codehilite .sh{background-color:#fff0f0}.codehilite .si{background-color:#e0e0e0}.codehilite .sx{color:#d02000;background-color:#fff0f0}.codehilite .sr{color:#000;background-color:#fff0ff}.codehilite .s1{background-color:#fff0f0}.codehilite .ss{color:#a06000}.codehilite .bp{color:#007020}.codehilite .vc{color:#306090}.codehilite .vg{color:#d07000;font-weight:bold}.codehilite .vi{color:#3030b0}.codehilite .il{color:#0000d0;font-weight:bold}.twitter-typeahead{width:100%;position:relative}.twitter-typeahead .tt-query,.twitter-typeahead .tt-hint{margin-bottom:0;width:100%;position:absolute;top:0;left:0}.twitter-typeahead .tt-hint{color:#a1a1a1;z-index:1;padding:6px 12px;border:1px solid transparent}.twitter-typeahead .tt-query{z-index:2;border-radius:4px !important;border-top-right-radius:0 !important;border-bottom-right-radius:0 !important}.tt-dropdown-menu{min-width:160px;margin-top:2px;padding:5px 0;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);*border-right-width:2px;*border-bottom-width:2px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);-moz-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box}.tt-suggestion{display:block;padding:3px 20px}.tt-suggestion.tt-is-under-cursor{color:#fff;background-color:#0081c2;background-image:-moz-linear-gradient(top, #08c, #0077b3);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#08c), to(#0077b3));background-image:-webkit-linear-gradient(top, #08c, #0077b3);background-image:-o-linear-gradient(top, #08c, #0077b3);background-image:linear-gradient(to bottom, #08c, #0077b3);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0)}.tt-suggestion.tt-is-under-cursor a{color:#fff}.tt-suggestion p{margin:0}.twitter-typeahead .tt-hint{display:block;height:38px;padding:8px 12px;font-size:14px;line-height:1.42857143;border:1px solid transparent}.twitter-typeahead{display:block !important}.dropdown-submenu{position:relative}.dropdown-submenu>.dropdown-menu{top:0;left:100%;margin-top:-6px;margin-left:-1px;-webkit-border-radius:0 6px 6px 6px;-moz-border-radius:0 6px 6px;border-radius:0 6px 6px 6px}.dropdown-submenu:hover>.dropdown-menu{display:block}.dropdown-submenu>a:after{display:block;content:" ";float:right;width:0;height:0;border-color:transparent;border-style:solid;border-width:5px 0 5px 5px;border-left-color:#ccc;margin-top:5px;margin-right:-10px}.dropdown-submenu:hover>a:after{border-left-color:#fff}.dropdown-submenu.pull-left{float:none}.dropdown-submenu.pull-left>.dropdown-menu{left:-100%;margin-left:10px;-webkit-border-radius:6px 0 6px 6px;-moz-border-radius:6px 0 6px 6px;border-radius:6px 0 6px 6px}
\ No newline at end of file
 */@font-face{font-family:'FontAwesome';src:url('../../font-awesome/font/fontawesome-webfont.eot?v=4.2.0');src:url('../../font-awesome/font/fontawesome-webfont.eot?#iefix&v=4.2.0') format('embedded-opentype'),url('../../font-awesome/font/fontawesome-webfont.woff?v=4.2.0') format('woff'),url('../../font-awesome/font/fontawesome-webfont.ttf?v=4.2.0') format('truetype'),url('../../font-awesome/font/fontawesome-webfont.svg?v=4.2.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.wiki-article .codehilite-wrap{overflow:auto;max-width:auto;max-height:250px}.wiki-article .codehilitetable pre{max-width:none;max-height:none;border:0;background-color:transparent}.wiki-article .codehilitetable .linenos{width:1%;white-space:nowrap}.codehilite .hll{background-color:#ffc}.codehilite .c{color:#808080}.codehilite .err{color:#f00000;background-color:#f0a0a0}.codehilite .k{color:#008000;font-weight:bold}.codehilite .o{color:#303030}.codehilite .cm{color:#808080}.codehilite .cp{color:#507090}.codehilite .c1{color:#808080}.codehilite .cs{color:#c00;font-weight:bold}.codehilite .gd{color:#a00000}.codehilite .ge{font-style:italic}.codehilite .gr{color:#f00}.codehilite .gh{color:#000080;font-weight:bold}.codehilite .gi{color:#00a000}.codehilite .go{color:#808080}.codehilite .gp{color:#c65d09;font-weight:bold}.codehilite .gs{font-weight:bold}.codehilite .gu{color:#800080;font-weight:bold}.codehilite .gt{color:#0040d0}.codehilite .kc{color:#008000;font-weight:bold}.codehilite .kd{color:#008000;font-weight:bold}.codehilite .kn{color:#008000;font-weight:bold}.codehilite .kp{color:#003080;font-weight:bold}.codehilite .kr{color:#008000;font-weight:bold}.codehilite .kt{color:#303090;font-weight:bold}.codehilite .m{color:#6000e0;font-weight:bold}.codehilite .s{background-color:#fff0f0}.codehilite .na{color:#0000c0}.codehilite .nb{color:#007020}.codehilite .nc{color:#b00060;font-weight:bold}.codehilite .no{color:#003060;font-weight:bold}.codehilite .nd{color:#505050;font-weight:bold}.codehilite .ni{color:#800000;font-weight:bold}.codehilite .ne{color:#f00000;font-weight:bold}.codehilite .nf{color:#0060b0;font-weight:bold}.codehilite .nl{color:#907000;font-weight:bold}.codehilite .nn{color:#0e84b5;font-weight:bold}.codehilite .nt{color:#007000}.codehilite .nv{color:#906030}.codehilite .ow{color:#000;font-weight:bold}.codehilite .w{color:#bbb}.codehilite .mf{color:#6000e0;font-weight:bold}.codehilite .mh{color:#005080;font-weight:bold}.codehilite .mi{color:#0000d0;font-weight:bold}.codehilite .mo{color:#4000e0;font-weight:bold}.codehilite .sb{background-color:#fff0f0}.codehilite .sc{color:#0040d0}.codehilite .sd{color:#d04020}.codehilite .s2{background-color:#fff0f0}.codehilite .se{color:#606060;font-weight:bold;background-color:#fff0f0}.codehilite .sh{background-color:#fff0f0}.codehilite .si{background-color:#e0e0e0}.codehilite .sx{color:#d02000;background-color:#fff0f0}.codehilite .sr{color:#000;background-color:#fff0ff}.codehilite .s1{background-color:#fff0f0}.codehilite .ss{color:#a06000}.codehilite .bp{color:#007020}.codehilite .vc{color:#306090}.codehilite .vg{color:#d07000;font-weight:bold}.codehilite .vi{color:#3030b0}.codehilite .il{color:#0000d0;font-weight:bold}.twitter-typeahead{width:100%;position:relative}.twitter-typeahead .tt-query,.twitter-typeahead .tt-hint{margin-bottom:0;width:100%;position:absolute;top:0;left:0}.twitter-typeahead .tt-hint{color:#a1a1a1;z-index:1;padding:6px 12px;border:1px solid transparent}.twitter-typeahead .tt-query{z-index:2;border-radius:4px !important;border-top-right-radius:0 !important;border-bottom-right-radius:0 !important}.tt-dropdown-menu{min-width:160px;margin-top:2px;padding:5px 0;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);*border-right-width:2px;*border-bottom-width:2px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);-moz-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box}.tt-suggestion{display:block;padding:3px 20px}.tt-suggestion.tt-is-under-cursor{color:#fff;background-color:#0081c2;background-image:-moz-linear-gradient(top, #08c, #0077b3);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#08c), to(#0077b3));background-image:-webkit-linear-gradient(top, #08c, #0077b3);background-image:-o-linear-gradient(top, #08c, #0077b3);background-image:linear-gradient(to bottom, #08c, #0077b3);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0)}.tt-suggestion.tt-is-under-cursor a{color:#fff}.tt-suggestion p{margin:0}.twitter-typeahead .tt-hint{display:block;height:38px;padding:8px 12px;font-size:14px;line-height:1.42857143;border:1px solid transparent}.twitter-typeahead{display:block !important}.dropdown-submenu{position:relative}.dropdown-submenu>.dropdown-menu{top:0;left:100%;margin-top:-6px;margin-left:-1px;-webkit-border-radius:0 6px 6px 6px;-moz-border-radius:0 6px 6px;border-radius:0 6px 6px 6px}.dropdown-submenu:hover>.dropdown-menu{display:block}.dropdown-submenu>a:after{display:block;content:" ";float:right;width:0;height:0;border-color:transparent;border-style:solid;border-width:5px 0 5px 5px;border-left-color:#ccc;margin-top:5px;margin-right:-10px}.dropdown-submenu:hover>a:after{border-left-color:#fff}.dropdown-submenu.pull-left{float:none}.dropdown-submenu.pull-left>.dropdown-menu{left:-100%;margin-left:10px;-webkit-border-radius:6px 0 6px 6px;-moz-border-radius:6px 0 6px 6px;border-radius:6px 0 6px 6px}

M src/wiki/templatetags/wiki_tags.py => src/wiki/templatetags/wiki_tags.py +30 -30
@@ 36,54 36,54 @@ def article_for_object(context, obj):
    if True or obj not in _cache:
        try:
            article = models.ArticleForObject.objects.get(
                content_type=content_type,
                object_id=obj.pk).article
                content_type=content_type, object_id=obj.pk
            ).article
        except models.ArticleForObject.DoesNotExist:
            article = None
        _cache[obj] = article
    return _cache[obj]


@register.inclusion_tag('wiki/includes/render.html', takes_context=True)
@register.inclusion_tag("wiki/includes/render.html", takes_context=True)
def wiki_render(context, article, preview_content=None):

    if preview_content:
        content = article.render(preview_content=preview_content)
    elif article.current_revision:
        content = article.get_cached_content(user=context.get('user'))
        content = article.get_cached_content(user=context.get("user"))
    else:
        content = None

    context.update({
        'article': article,
        'content': content,
        'preview': preview_content is not None,
        'plugins': plugin_registry.get_plugins(),
        'STATIC_URL': django_settings.STATIC_URL,
        'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
    })
    context.update(
        {
            "article": article,
            "content": content,
            "preview": preview_content is not None,
            "plugins": plugin_registry.get_plugins(),
            "STATIC_URL": django_settings.STATIC_URL,
            "CACHE_TIMEOUT": settings.CACHE_TIMEOUT,
        }
    )
    return context


@register.inclusion_tag('wiki/includes/form.html', takes_context=True)
@register.inclusion_tag("wiki/includes/form.html", takes_context=True)
def wiki_form(context, form_obj):
    if not isinstance(form_obj, BaseForm):
        raise TypeError(
            "Error including form, it's not a form, it's a %s" %
            type(form_obj))
    context.update({'form': form_obj})
            "Error including form, it's not a form, it's a %s" % type(form_obj)
        )
    context.update({"form": form_obj})
    return context


@register.inclusion_tag('wiki/includes/messages.html', takes_context=True)
@register.inclusion_tag("wiki/includes/messages.html", takes_context=True)
def wiki_messages(context):

    messages = context.get('messages', [])
    messages = context.get("messages", [])
    for message in messages:
        message.css_class = settings.MESSAGE_TAG_CSS_CLASS[message.level]
    context.update({
        'messages': messages
    })
    context.update({"messages": messages})
    return context




@@ 116,22 116,22 @@ def get_content_snippet(content, keyword, max_words=30):

    if match_position != -1:
        try:
            match_start = content.rindex(' ', 0, match_position) + 1
            match_start = content.rindex(" ", 0, match_position) + 1
        except ValueError:
            match_start = 0
        try:
            match_end = content.index(' ', match_position + len(keyword))
            match_end = content.index(" ", match_position + len(keyword))
        except ValueError:
            match_end = len(content)
        all_before = clean_text(content[:match_start])
        match = content[match_start:match_end]
        all_after = clean_text(content[match_end:])
        before_words = all_before[-max_words // 2:]
        after_words = all_after[:max_words - len(before_words)]
        before_words = all_before[-max_words // 2 :]
        after_words = all_after[: max_words - len(before_words)]
        before = " ".join(before_words)
        after = " ".join(after_words)
        html = ("%s %s %s" % (before, striptags(match), after)).strip()
        kw_p = re.compile(r'(\S*%s\S*)' % keyword, re.IGNORECASE)
        kw_p = re.compile(r"(\S*%s\S*)" % keyword, re.IGNORECASE)
        html = kw_p.sub(r"<strong>\1</strong>", html)

        return mark_safe(html)


@@ 185,12 185,12 @@ def is_locked(model):

@register.simple_tag(takes_context=True)
def login_url(context):
    request = context['request']
    qs = request.META.get('QUERY_STRING', '')
    request = context["request"]
    qs = request.META.get("QUERY_STRING", "")
    if qs:
        qs = urlquote('?' + qs)
        qs = urlquote("?" + qs)
    else:
        qs = ''
        qs = ""
    return settings.LOGIN_URL + "?next=" + request.path + qs



M src/wiki/urls.py => src/wiki/urls.py +163 -98
@@ 6,7 6,7 @@ from wiki.core.plugins import registry
from wiki.views import accounts, article, deleted_list

urlpatterns = [
    re_path(r'^', sites.site.urls),
    re_path(r"^", sites.site.urls),
]




@@ 63,48 63,58 @@ class WikiURLPatterns:

    def get_root_urls(self):
        urlpatterns = [
            re_path(r'^$',
            re_path(
                r"^$",
                self.article_view_class.as_view(),
                name='root',
                kwargs={'path': ''}),
            re_path(r'^create-root/$',
                article.CreateRootView.as_view(),
                name='root_create'),
            re_path(r'^missing-root/$',
                name="root",
                kwargs={"path": ""},
            ),
            re_path(
                r"^create-root/$", article.CreateRootView.as_view(), name="root_create"
            ),
            re_path(
                r"^missing-root/$",
                article.MissingRootView.as_view(),
                name='root_missing'),
            re_path(r'^_search/$',
                self.search_view_class.as_view(),
                name='search'),
            re_path(r'^_revision/diff/(?P<revision_id>[0-9]+)/$',
                name="root_missing",
            ),
            re_path(r"^_search/$", self.search_view_class.as_view(), name="search"),
            re_path(
                r"^_revision/diff/(?P<revision_id>[0-9]+)/$",
                self.article_diff_view_class.as_view(),
                name='diff'),
                name="diff",
            ),
        ]
        return urlpatterns

    def get_deleted_list_urls(self):
        urlpatterns = [
            re_path('^_admin/$',
                self.deleted_list_view_class.as_view(),
                name="deleted_list"),
            re_path(
                "^_admin/$", self.deleted_list_view_class.as_view(), name="deleted_list"
            ),
        ]
        return urlpatterns

    def get_accounts_urls(self):
        if settings.ACCOUNT_HANDLING:
            urlpatterns = [
                re_path(r'^_accounts/sign-up/$',
                re_path(
                    r"^_accounts/sign-up/$",
                    self.signup_view_class.as_view(),
                    name='signup'),
                re_path(r'^_accounts/logout/$',
                    name="signup",
                ),
                re_path(
                    r"^_accounts/logout/$",
                    self.logout_view_class.as_view(),
                    name='logout'),
                re_path(r'^_accounts/login/$',
                    self.login_view_class.as_view(),
                    name='login'),
                re_path(r'^_accounts/settings/$',
                    name="logout",
                ),
                re_path(
                    r"^_accounts/login/$", self.login_view_class.as_view(), name="login"
                ),
                re_path(
                    r"^_accounts/settings/$",
                    self.profile_update_view_class.as_view(),
                    name='profile_update'),
                    name="profile_update",
                ),
            ]
        else:
            urlpatterns = []


@@ 115,114 125,164 @@ class WikiURLPatterns:
            # This one doesn't work because it don't know
            # where to redirect after...
            re_path(
                r'^_revision/change/(?P<article_id>[0-9]+)/(?P<revision_id>[0-9]+)/$',
                r"^_revision/change/(?P<article_id>[0-9]+)/(?P<revision_id>[0-9]+)/$",
                self.revision_change_view_class.as_view(),
                name='change_revision'),
            re_path(r'^_revision/preview/(?P<article_id>[0-9]+)/$',
                name="change_revision",
            ),
            re_path(
                r"^_revision/preview/(?P<article_id>[0-9]+)/$",
                self.article_preview_view_class.as_view(),
                name='preview_revision'),
                name="preview_revision",
            ),
            re_path(
                r'^_revision/merge/(?P<article_id>[0-9]+)/(?P<revision_id>[0-9]+)/preview/$',
                r"^_revision/merge/(?P<article_id>[0-9]+)/(?P<revision_id>[0-9]+)/preview/$",
                self.revision_merge_view_class.as_view(preview=True),
                name='merge_revision_preview'),
                name="merge_revision_preview",
            ),
        ]
        return urlpatterns

    def get_article_urls(self):
        urlpatterns = [
            # Paths decided by article_ids
            re_path(r'^(?P<article_id>[0-9]+)/$',
            re_path(
                r"^(?P<article_id>[0-9]+)/$",
                self.article_view_class.as_view(),
                name='get'),
            re_path(r'^(?P<article_id>[0-9]+)/delete/$',
                name="get",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/delete/$",
                self.article_delete_view_class.as_view(),
                name='delete'),
            re_path(r'^(?P<article_id>[0-9]+)/deleted/$',
                name="delete",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/deleted/$",
                self.article_deleted_view_class.as_view(),
                name='deleted'),
            re_path(r'^(?P<article_id>[0-9]+)/edit/$',
                name="deleted",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/edit/$",
                self.article_edit_view_class.as_view(),
                name='edit'),
            re_path(r'^(?P<article_id>[0-9]+)/move/$',
                name="edit",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/move/$",
                self.article_move_view_class.as_view(),
                name='move'),
            re_path(r'^(?P<article_id>[0-9]+)/preview/$',
                name="move",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/preview/$",
                self.article_preview_view_class.as_view(),
                name='preview'),
            re_path(r'^(?P<article_id>[0-9]+)/history/$',
                name="preview",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/history/$",
                self.article_history_view_class.as_view(),
                name='history'),
            re_path(r'^(?P<article_id>[0-9]+)/settings/$',
                name="history",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/settings/$",
                self.article_settings_view_class.as_view(),
                name='settings'),
            re_path(r'^(?P<article_id>[0-9]+)/source/$',
                name="settings",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/source/$",
                self.article_source_view_class.as_view(),
                name='source'),
                name="source",
            ),
            re_path(
                r'^(?P<article_id>[0-9]+)/revision/change/(?P<revision_id>[0-9]+)/$',
                r"^(?P<article_id>[0-9]+)/revision/change/(?P<revision_id>[0-9]+)/$",
                self.revision_change_view_class.as_view(),
                name='change_revision'),
                name="change_revision",
            ),
            re_path(
                r'^(?P<article_id>[0-9]+)/revision/merge/(?P<revision_id>[0-9]+)/$',
                r"^(?P<article_id>[0-9]+)/revision/merge/(?P<revision_id>[0-9]+)/$",
                self.revision_merge_view_class.as_view(),
                name='merge_revision'),
            re_path(r'^(?P<article_id>[0-9]+)/plugin/(?P<slug>\w+)/$',
                name="merge_revision",
            ),
            re_path(
                r"^(?P<article_id>[0-9]+)/plugin/(?P<slug>\w+)/$",
                self.article_plugin_view_class.as_view(),
                name='plugin'),
                name="plugin",
            ),
        ]
        return urlpatterns

    def get_article_path_urls(self):
        urlpatterns = [
            # Paths decided by URLs
            re_path(r'^(?P<path>.+/|)_create/$',
            re_path(
                r"^(?P<path>.+/|)_create/$",
                self.article_create_view_class.as_view(),
                name='create'),
            re_path(r'^(?P<path>.+/|)_delete/$',
                name="create",
            ),
            re_path(
                r"^(?P<path>.+/|)_delete/$",
                self.article_delete_view_class.as_view(),
                name='delete'),
            re_path(r'^(?P<path>.+/|)_deleted/$',
                name="delete",
            ),
            re_path(
                r"^(?P<path>.+/|)_deleted/$",
                self.article_deleted_view_class.as_view(),
                name='deleted'),
            re_path(r'^(?P<path>.+/|)_edit/$',
                name="deleted",
            ),
            re_path(
                r"^(?P<path>.+/|)_edit/$",
                self.article_edit_view_class.as_view(),
                name='edit'),
            re_path(r'^(?P<path>.+/|)_move/$',
                name="edit",
            ),
            re_path(
                r"^(?P<path>.+/|)_move/$",
                self.article_move_view_class.as_view(),
                name='move'),
            re_path(r'^(?P<path>.+/|)_preview/$',
                name="move",
            ),
            re_path(
                r"^(?P<path>.+/|)_preview/$",
                self.article_preview_view_class.as_view(),
                name='preview'),
            re_path(r'^(?P<path>.+/|)_history/$',
                name="preview",
            ),
            re_path(
                r"^(?P<path>.+/|)_history/$",
                self.article_history_view_class.as_view(),
                name='history'),
            re_path(r'^(?P<path>.+/|)_dir/$',
                name="history",
            ),
            re_path(
                r"^(?P<path>.+/|)_dir/$",
                self.article_dir_view_class.as_view(),
                name='dir'),
            re_path(r'^(?P<path>.+/|)_search/$',
                name="dir",
            ),
            re_path(
                r"^(?P<path>.+/|)_search/$",
                self.search_view_class.as_view(),
                name='search'),
            re_path(r'^(?P<path>.+/|)_settings/$',
                name="search",
            ),
            re_path(
                r"^(?P<path>.+/|)_settings/$",
                self.article_settings_view_class.as_view(),
                name='settings'),
            re_path(r'^(?P<path>.+/|)_source/$',
                name="settings",
            ),
            re_path(
                r"^(?P<path>.+/|)_source/$",
                self.article_source_view_class.as_view(),
                name='source'),
                name="source",
            ),
            re_path(
                r'^(?P<path>.+/|)_revision/change/(?P<revision_id>[0-9]+)/$',
                r"^(?P<path>.+/|)_revision/change/(?P<revision_id>[0-9]+)/$",
                self.revision_change_view_class.as_view(),
                name='change_revision'),
                name="change_revision",
            ),
            re_path(
                r'^(?P<path>.+/|)_revision/merge/(?P<revision_id>[0-9]+)/$',
                r"^(?P<path>.+/|)_revision/merge/(?P<revision_id>[0-9]+)/$",
                self.revision_merge_view_class.as_view(),
                name='merge_revision'),
            re_path(r'^(?P<path>.+/|)_plugin/(?P<slug>\w+)/$',
                name="merge_revision",
            ),
            re_path(
                r"^(?P<path>.+/|)_plugin/(?P<slug>\w+)/$",
                self.article_plugin_view_class.as_view(),
                name='plugin'),
                name="plugin",
            ),
            # This should always go last!
            re_path(r'^(?P<path>.+/|)$',
                self.article_view_class.as_view(),
                name='get'),
            re_path(r"^(?P<path>.+/|)$", self.article_view_class.as_view(), name="get"),
        ]
        return urlpatterns



@@ 230,18 290,22 @@ class WikiURLPatterns:
    def get_plugin_urls():
        urlpatterns = []
        for plugin in registry.get_plugins().values():
            slug = getattr(plugin, 'slug', None)
            slug = getattr(plugin, "slug", None)
            if slug:
                article_urlpatterns = plugin.urlpatterns.get('article', [])
                article_urlpatterns = plugin.urlpatterns.get("article", [])
                urlpatterns += [
                    re_path(r'^(?P<article_id>[0-9]+)/plugin/' + slug + '/',
                        include(article_urlpatterns)),
                    re_path(r'^(?P<path>.+/|)_plugin/' + slug + '/',
                        include(article_urlpatterns)),
                    re_path(
                        r"^(?P<article_id>[0-9]+)/plugin/" + slug + "/",
                        include(article_urlpatterns),
                    ),
                    re_path(
                        r"^(?P<path>.+/|)_plugin/" + slug + "/",
                        include(article_urlpatterns),
                    ),
                ]
                root_urlpatterns = plugin.urlpatterns.get('root', [])
                root_urlpatterns = plugin.urlpatterns.get("root", [])
                urlpatterns += [
                    re_path(r'^_plugin/' + slug + '/', include(root_urlpatterns)),
                    re_path(r"^_plugin/" + slug + "/", include(root_urlpatterns)),
                ]
        return urlpatterns



@@ 253,18 317,19 @@ def get_pattern(app_name="wiki", namespace="wiki", url_config_class=None):
       https://docs.djangoproject.com/en/dev/topics/http/urls/#topics-http-reversing-url-namespaces
    """
    import warnings

    warnings.warn(
        "wiki.urls.get_pattern is deprecated and will be removed in next version, just `include('wiki.urls')` in your urlconf",
        DeprecationWarning
        DeprecationWarning,
    )
    if url_config_class is None:
        url_config_classname = getattr(settings, 'URL_CONFIG_CLASS', None)
        url_config_classname = getattr(settings, "URL_CONFIG_CLASS", None)
        if url_config_classname is None:
            url_config_class = WikiURLPatterns
        else:
            warnings.warn(
                "URL_CONFIG_CLASS is deprecated and will be removed in next version, override `wiki.sites.WikiSite` instead",
                DeprecationWarning
                DeprecationWarning,
            )
            url_config_class = import_string(url_config_classname)
    urlpatterns = url_config_class().get_urls()

M src/wiki/views/accounts.py => src/wiki/views/accounts.py +20 -17
@@ 11,7 11,11 @@ SETTINGS.LOGOUT_URL

from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth import get_user_model, login as auth_login, logout as auth_logout
from django.contrib.auth import (
    get_user_model,
    login as auth_login,
    logout as auth_logout,
)
from django.contrib.auth.forms import AuthenticationForm
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse


@@ 31,32 35,31 @@ class Signup(CreateView):
    def dispatch(self, request, *args, **kwargs):
        # Let logged in super users continue
        if not request.user.is_anonymous and not request.user.is_superuser:
            return redirect('wiki:root')
            return redirect("wiki:root")
        # If account handling is disabled, don't go here
        if not settings.ACCOUNT_HANDLING:
            return redirect(settings.SIGNUP_URL)
        # Allow superusers to use signup page...
        if not request.user.is_superuser and not settings.ACCOUNT_SIGNUP_ALLOWED:
            c = {'error_msg': _('Account signup is only allowed for administrators.')}
            c = {"error_msg": _("Account signup is only allowed for administrators.")}
            return render(request, "wiki/error.html", context=c)

        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['honeypot_class'] = context['form'].honeypot_class
        context['honeypot_jsfunction'] = context['form'].honeypot_jsfunction
        context["honeypot_class"] = context["form"].honeypot_class
        context["honeypot_jsfunction"] = context["form"].honeypot_jsfunction
        return context

    def get_success_url(self, *args):
        messages.success(
            self.request,
            _('You are now signed up... and now you can sign in!'))
            self.request, _("You are now signed up... and now you can sign in!")
        )
        return reverse("wiki:login")


class Logout(View):

    def dispatch(self, request, *args, **kwargs):
        if not settings.ACCOUNT_HANDLING:
            return redirect(settings.LOGOUT_URL)


@@ 75,7 78,7 @@ class Login(FormView):

    def dispatch(self, request, *args, **kwargs):
        if not request.user.is_anonymous:
            return redirect('wiki:root')
            return redirect("wiki:root")
        if not settings.ACCOUNT_HANDLING:
            return redirect(settings.LOGIN_URL)
        return super().dispatch(request, *args, **kwargs)


@@ 83,23 86,23 @@ class Login(FormView):
    def get_form_kwargs(self):
        self.request.session.set_test_cookie()
        kwargs = super().get_form_kwargs()
        kwargs['request'] = self.request
        kwargs["request"] = self.request
        return kwargs

    def post(self, request, *args, **kwargs):
        self.referer = request.session.get('login_referer', '')
        self.referer = request.session.get("login_referer", "")
        return super().post(request, *args, **kwargs)

    def get(self, request, *args, **kwargs):
        self.referer = request.META.get('HTTP_REFERER', '')
        request.session['login_referer'] = self.referer
        self.referer = request.META.get("HTTP_REFERER", "")
        request.session["login_referer"] = self.referer
        return super().get(request, *args, **kwargs)

    def form_valid(self, form, *args, **kwargs):
        auth_login(self.request, form.get_user())
        messages.info(self.request, _("You are now logged in! Have fun!"))
        if self.request.GET.get("next", None):
            return redirect(self.request.GET['next'])
            return redirect(self.request.GET["next"])
        if django_settings.LOGIN_REDIRECT_URL:
            return redirect(django_settings.LOGIN_REDIRECT_URL)
        else:


@@ 120,12 123,12 @@ class Update(UpdateView):
        """
        Save the initial referer
        """
        self.referer = request.META.get('HTTP_REFERER', '')
        request.session['login_referer'] = self.referer
        self.referer = request.META.get("HTTP_REFERER", "")
        request.session["login_referer"] = self.referer
        return super().get(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        self.referer = request.session.get('login_referer', '')
        self.referer = request.session.get("login_referer", "")
        return super().post(request, *args, **kwargs)

    def form_valid(self, form):

M src/wiki/views/article.py => src/wiki/views/article.py +249 -206
@@ 12,7 12,14 @@ from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _, ngettext
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import DetailView, FormView, ListView, RedirectView, TemplateView, View
from django.views.generic import (
    DetailView,
    FormView,
    ListView,
    RedirectView,
    TemplateView,
    View,
)
from wiki import editors, forms, models
from wiki.conf import settings
from wiki.core import permissions


@@ 36,7 43,7 @@ class ArticleView(ArticleMixin, TemplateView):
        return super().dispatch(request, article, *args, **kwargs)

    def get_context_data(self, **kwargs):
        kwargs['selected_tab'] = 'view'
        kwargs["selected_tab"] = "view"
        return ArticleMixin.get_context_data(self, **kwargs)




@@ 55,18 62,22 @@ class Create(FormView, ArticleMixin):
        if form_class is None:
            form_class = self.get_form_class()
        kwargs = self.get_form_kwargs()
        initial = kwargs.get('initial', {})
        initial['slug'] = self.request.GET.get('slug', None)
        kwargs['initial'] = initial
        initial = kwargs.get("initial", {})
        initial["slug"] = self.request.GET.get("slug", None)
        kwargs["initial"] = initial
        form = form_class(self.request, self.urlpath, **kwargs)
        form.fields['slug'].widget = forms.TextInputPrepend(
            prepend='/' + self.urlpath.path,
        form.fields["slug"].widget = forms.TextInputPrepend(
            prepend="/" + self.urlpath.path,
            attrs={
                # Make patterns force lowercase if we are case insensitive to bless the user with a
                # bit of strictness, anyways
                'pattern': '[a-z0-9_-]+' if not settings.URL_CASE_SENSITIVE else '[a-zA-Z0-9_-]+',
                'title': 'Lowercase letters, numbers, hyphens and underscores' if not settings.URL_CASE_SENSITIVE else 'Letters, numbers, hyphens and underscores',
            }
                "pattern": "[a-z0-9_-]+"
                if not settings.URL_CASE_SENSITIVE
                else "[a-zA-Z0-9_-]+",
                "title": "Lowercase letters, numbers, hyphens and underscores"
                if not settings.URL_CASE_SENSITIVE
                else "Letters, numbers, hyphens and underscores",
            },
        )
        return form



@@ 76,39 87,42 @@ class Create(FormView, ArticleMixin):
                self.request,
                self.article,
                self.urlpath,
                form.cleaned_data['slug'],
                form.cleaned_data['title'],
                form.cleaned_data['content'],
                form.cleaned_data['summary']
                form.cleaned_data["slug"],
                form.cleaned_data["title"],
                form.cleaned_data["content"],
                form.cleaned_data["summary"],
            )
            messages.success(
                self.request,
                _("New article '%s' created.") %
                self.newpath.article.current_revision.title)
                _("New article '%s' created.")
                % self.newpath.article.current_revision.title,
            )
        # TODO: Handle individual exceptions better and give good feedback.
        except Exception as e:
            log.exception("Exception creating article.")
            if self.request.user.is_superuser:
                messages.error(
                    self.request,
                    _("There was an error creating this article: %s") %
                    str(e))
                    _("There was an error creating this article: %s") % str(e),
                )
            else:
                messages.error(self.request, _("There was an error creating this article."))
            return redirect('wiki:get', '')
                messages.error(
                    self.request, _("There was an error creating this article.")
                )
            return redirect("wiki:get", "")

        return self.get_success_url()

    def get_success_url(self):
        return redirect('wiki:get', self.newpath.path)
        return redirect("wiki:get", self.newpath.path)

    def get_context_data(self, **kwargs):
        c = ArticleMixin.get_context_data(self, **kwargs)
        c['form'] = self.get_form()
        c['parent_urlpath'] = self.urlpath
        c['parent_article'] = self.article
        c['create_form'] = c.pop('form', None)
        c['editor'] = editors.getEditor()
        c["form"] = self.get_form()
        c["parent_urlpath"] = self.urlpath
        c["parent_article"] = self.article
        c["create_form"] = c.pop("form", None)
        c["editor"] = editors.getEditor()
        return c




@@ 124,15 138,12 @@ class Delete(FormView, ArticleMixin):
    def dispatch1(self, request, article, *args, **kwargs):
        """Deleted view needs to access this method without a decorator,
        therefore it is separate."""
        urlpath = kwargs.get('urlpath', None)
        urlpath = kwargs.get("urlpath", None)
        # Where to go after deletion...
        self.next = ""
        self.cannot_delete_root = False
        if urlpath and urlpath.parent:
            self.next = reverse(
                'wiki:get',
                kwargs={
                    'path': urlpath.parent.path})
            self.next = reverse("wiki:get", kwargs={"path": urlpath.parent.path})
        elif urlpath:
            # We are a urlpath with no parent. This is the root
            self.cannot_delete_root = True


@@ 141,7 152,8 @@ class Delete(FormView, ArticleMixin):
            for art_obj in article.articleforobject_set.filter(is_mptt=True):
                if art_obj.content_object.parent:
                    self.next = reverse(
                        'wiki:get', kwargs={'article_id': art_obj.content_object.parent.article.id}
                        "wiki:get",
                        kwargs={"article_id": art_obj.content_object.parent.article.id},
                    )
                else:
                    self.cannot_delete_root = True


@@ 149,24 161,24 @@ class Delete(FormView, ArticleMixin):
        return super().dispatch(request, article, *args, **kwargs)

    def get_initial(self):
        return {'revision': self.article.current_revision}
        return {"revision": self.article.current_revision}

    def get_form(self, form_class=None):
        form = super().get_form(form_class=form_class)
        if self.article.can_moderate(self.request.user):
            form.fields['purge'].widget = forms.forms.CheckboxInput()
            form.fields["purge"].widget = forms.forms.CheckboxInput()
        return form

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['article'] = self.article
        kwargs['has_children'] = bool(self.children_slice)
        kwargs["article"] = self.article
        kwargs["has_children"] = bool(self.children_slice)
        return kwargs

    def form_valid(self, form):
        cd = form.cleaned_data

        purge = cd['purge']
        purge = cd["purge"]

        # If we are purging, only moderators can delete articles with children
        cannot_delete_children = False


@@ 177,8 189,11 @@ class Delete(FormView, ArticleMixin):
        if self.cannot_delete_root or cannot_delete_children:
            messages.error(
                self.request,
                _('This article cannot be deleted because it has children or is a root article.'))
            return redirect('wiki:get', article_id=self.article.id)
                _(
                    "This article cannot be deleted because it has children or is a root article."
                ),
            )
            return redirect("wiki:get", article_id=self.article.id)

        if can_moderate and purge:
            # First, remove children


@@ 187,7 202,10 @@ class Delete(FormView, ArticleMixin):
            self.article.delete()
            messages.success(
                self.request,
                _('This article together with all its contents are now completely gone! Thanks!'))
                _(
                    "This article together with all its contents are now completely gone! Thanks!"
                ),
            )
        else:
            revision = models.ArticleRevision()
            revision.inherit_predecessor(self.article)


@@ 196,8 214,11 @@ class Delete(FormView, ArticleMixin):
            self.article.add_revision(revision)
            messages.success(
                self.request,
                _('The article "%s" is now marked as deleted! Thanks for keeping the site free from unwanted material!') %
                revision.title)
                _(
                    'The article "%s" is now marked as deleted! Thanks for keeping the site free from unwanted material!'
                )
                % revision.title,
            )
        return self.get_success_url()

    def get_success_url(self):


@@ 208,24 229,25 @@ class Delete(FormView, ArticleMixin):
        if self.children_slice and not self.article.can_moderate(self.request.user):
            cannot_delete_children = True

        kwargs['delete_form'] = self.get_form()
        kwargs['form'] = kwargs['delete_form']
        kwargs['cannot_delete_root'] = self.cannot_delete_root
        kwargs['delete_children'] = self.children_slice[:20]
        kwargs['delete_children_more'] = len(self.children_slice) > 20
        kwargs['cannot_delete_children'] = cannot_delete_children
        kwargs["delete_form"] = self.get_form()
        kwargs["form"] = kwargs["delete_form"]
        kwargs["cannot_delete_root"] = self.cannot_delete_root
        kwargs["delete_children"] = self.children_slice[:20]
        kwargs["delete_children_more"] = len(self.children_slice) > 20
        kwargs["cannot_delete_children"] = cannot_delete_children
        return super().get_context_data(**kwargs)


class Edit(ArticleMixin, FormView):

    """Edit an article and process sidebar plugins."""

    form_class = forms.EditForm
    template_name = "wiki/edit.html"

    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.orig_content = kwargs.pop('content', None)
        self.orig_content = kwargs.pop("content", None)
        self.sidebar_plugins = plugin_registry.get_sidebar()
        self.sidebar = []
        return super().dispatch(request, article, *args, **kwargs)


@@ 233,9 255,8 @@ class Edit(ArticleMixin, FormView):
    def get_initial(self):
        initial = FormView.get_initial(self)

        for field_name in ['title', 'content']:
            session_key = 'unsaved_article_%s_%d' % (
                field_name, self.article.id)
        for field_name in ["title", "content"]:
            session_key = "unsaved_article_%s_%d" % (field_name, self.article.id)
            if session_key in self.request.session:
                content = self.request.session[session_key]
                initial[field_name] = content


@@ 250,11 271,14 @@ class Edit(ArticleMixin, FormView):
        if form_class is None:
            form_class = self.get_form_class()
        kwargs = self.get_form_kwargs()
        if self.request.POST.get('save', '') != '1' and self.request.POST.get('preview') != '1':
            kwargs['data'] = None
            kwargs['files'] = None
            kwargs['no_clean'] = True
            kwargs['content'] = self.orig_content
        if (
            self.request.POST.get("save", "") != "1"
            and self.request.POST.get("preview") != "1"
        ):
            kwargs["data"] = None
            kwargs["files"] = None
            kwargs["no_clean"] = True
            kwargs["content"] = self.orig_content
        return form_class(self.request, self.article.current_revision, **kwargs)

    def get_sidebar_form_classes(self):


@@ 263,8 287,9 @@ class Edit(ArticleMixin, FormView):
        to identify which form is being saved."""
        form_classes = {}
        for cnt, plugin in enumerate(self.sidebar_plugins):
            form_classes['form%d' % cnt] = (
                plugin, plugin.sidebar.get('form_class', None)
            form_classes["form%d" % cnt] = (
                plugin,
                plugin.sidebar.get("form_class", None),
            )
        return form_classes



@@ 274,7 299,7 @@ class Edit(ArticleMixin, FormView):
        for form_id, (plugin, Form) in self.get_sidebar_form_classes().items():
            if Form:
                form = Form(self.article, self.request.user)
                setattr(form, 'form_id', form_id)
                setattr(form, "form_id", form_id)
            else:
                form = None
            self.sidebar.append((plugin, form))


@@ 285,12 310,13 @@ class Edit(ArticleMixin, FormView):
        self.sidebar_forms = []
        for form_id, (plugin, Form) in self.get_sidebar_form_classes().items():
            if Form:
                if form_id == self.request.GET.get('f', None):
                if form_id == self.request.GET.get("f", None):
                    form = Form(
                        self.article,
                        self.request,
                        data=self.request.POST,
                        files=self.request.FILES)
                        files=self.request.FILES,
                    )
                    if form.is_valid():
                        form.save()
                        usermessage = form.get_usermessage()


@@ 298,28 324,38 @@ class Edit(ArticleMixin, FormView):
                            messages.success(self.request, usermessage)
                        else:
                            messages.success(
                                self.request,
                                _('Your changes were saved.'))
                                self.request, _("Your changes were saved.")
                            )

                        title = form.cleaned_data['unsaved_article_title']
                        content = form.cleaned_data['unsaved_article_content']
                        title = form.cleaned_data["unsaved_article_title"]
                        content = form.cleaned_data["unsaved_article_content"]
                        orig_content = self.orig_content
                        if not orig_content:
                            orig_content = self.article.current_revision.content
                        if title != self.article.current_revision.title or content != orig_content:
                            request.session['unsaved_article_title_%d' % self.article.id] = title
                            request.session['unsaved_article_content_%d' % self.article.id] = content
                        if (
                            title != self.article.current_revision.title
                            or content != orig_content
                        ):
                            request.session[
                                "unsaved_article_title_%d" % self.article.id
                            ] = title
                            request.session[
                                "unsaved_article_content_%d" % self.article.id
                            ] = content
                            messages.warning(
                                request,
                                _('Please note that your article text has not yet been saved!'))
                                _(
                                    "Please note that your article text has not yet been saved!"
                                ),
                            )

                        if self.urlpath:
                            return redirect('wiki:edit', path=self.urlpath.path)
                        return redirect('wiki:edit', article_id=self.article.id)
                            return redirect("wiki:edit", path=self.urlpath.path)
                        return redirect("wiki:edit", article_id=self.article.id)

                else:
                    form = Form(self.article, self.request)
                setattr(form, 'form_id', form_id)
                setattr(form, "form_id", form_id)
            else:
                form = None
            self.sidebar.append((plugin, form))


@@ 330,29 366,29 @@ class Edit(ArticleMixin, FormView):
        (does not concern any sidebar forms!)."""
        revision = models.ArticleRevision()
        revision.inherit_predecessor(self.article)
        revision.title = form.cleaned_data['title']
        revision.content = form.cleaned_data['content']
        revision.user_message = form.cleaned_data['summary']
        revision.title = form.cleaned_data["title"]
        revision.content = form.cleaned_data["content"]
        revision.user_message = form.cleaned_data["summary"]
        revision.deleted = False
        revision.set_from_request(self.request)
        self.article.add_revision(revision)
        messages.success(
            self.request,
            _('A new revision of the article was successfully added.'))
            self.request, _("A new revision of the article was successfully added.")
        )
        return self.get_success_url()

    def get_success_url(self):
        """Go to the article view page when the article has been saved"""
        if self.urlpath:
            return redirect("wiki:get", path=self.urlpath.path)
        return redirect('wiki:get', article_id=self.article.id)
        return redirect("wiki:get", article_id=self.article.id)

    def get_context_data(self, **kwargs):
        kwargs['form'] = self.get_form()
        kwargs['edit_form'] = kwargs['form']
        kwargs['editor'] = editors.getEditor()
        kwargs['selected_tab'] = 'edit'
        kwargs['sidebar'] = self.sidebar
        kwargs["form"] = self.get_form()
        kwargs["edit_form"] = kwargs["form"]
        kwargs["editor"] = editors.getEditor()
        kwargs["selected_tab"] = "edit"
        kwargs["sidebar"] = self.sidebar
        return super().get_context_data(**kwargs)




@@ 373,8 409,8 @@ class Move(ArticleMixin, FormView):
        return form_class(**kwargs)

    def get_context_data(self, **kwargs):
        kwargs['form'] = self.get_form()
        kwargs['root_path'] = models.URLPath.root()
        kwargs["form"] = self.get_form()
        kwargs["root_path"] = models.URLPath.root()
        return super().get_context_data(**kwargs)

    @transaction.atomic


@@ 382,13 418,12 @@ class Move(ArticleMixin, FormView):
        if not self.urlpath.parent:
            messages.error(
                self.request,
                _('This article cannot be moved because it is a root article.')
                _("This article cannot be moved because it is a root article."),
            )
            return redirect('wiki:get', article_id=self.article.id)
            return redirect("wiki:get", article_id=self.article.id)

        dest_path = get_object_or_404(
            models.URLPath,
            pk=form.cleaned_data['destination']
            models.URLPath, pk=form.cleaned_data["destination"]
        )
        tmp_path = dest_path



@@ 396,9 431,9 @@ class Move(ArticleMixin, FormView):
            if tmp_path == self.urlpath:
                messages.error(
                    self.request,
                    _('This article cannot be moved to a child of itself.')
                    _("This article cannot be moved to a child of itself."),
                )
                return redirect('wiki:move', article_id=self.article.id)
                return redirect("wiki:move", article_id=self.article.id)
            tmp_path = tmp_path.parent

        # Clear cache to update article lists (Old links)


@@ 409,25 444,28 @@ class Move(ArticleMixin, FormView):
        old_path = self.urlpath.path

        self.urlpath.parent = dest_path
        self.urlpath.slug = form.cleaned_data['slug']
        self.urlpath.slug = form.cleaned_data["slug"]
        self.urlpath.save()

        # Reload url path form database
        self.urlpath = models.URLPath.objects.get(pk=self.urlpath.pk)

        # Use a copy of ourself (to avoid cache) and update article links again
        for ancestor in models.Article.objects.get(pk=self.article.pk).ancestor_objects():
        for ancestor in models.Article.objects.get(
            pk=self.article.pk
        ).ancestor_objects():
            ancestor.article.clear_cache()

        # Create a redirect page for every moved article
        # /old-slug
        # /old-slug/child
        # /old-slug/child/grand-child
        if form.cleaned_data['redirect']:
        if form.cleaned_data["redirect"]:

            # NB! Includes self!
            descendants = list(self.urlpath.get_descendants(
                include_self=True).order_by("level"))
            descendants = list(
                self.urlpath.get_descendants(include_self=True).order_by("level")
            )

            root_len = len(descendants[0].path)



@@ 440,8 478,8 @@ class Move(ArticleMixin, FormView):
                src_path = urljoin(old_path, dst_path[root_len:])
                src_len = len(src_path)
                pos = src_path.rfind("/", 0, src_len - 1)
                slug = src_path[pos + 1:src_len - 1]
                parent_urlpath = models.URLPath.get_by_path(src_path[0:max(pos, 0)])
                slug = src_path[pos + 1 : src_len - 1]
                parent_urlpath = models.URLPath.get_by_path(src_path[0 : max(pos, 0)])

                link = "[wiki:/{path}](wiki:/{path})".format(path=dst_path)
                urlpath_new = models.URLPath._create_urlpath_from_request(


@@ 461,14 499,12 @@ class Move(ArticleMixin, FormView):
                ngettext(
                    "Article successfully moved! Created {n} redirect.",
                    "Article successfully moved! Created {n} redirects.",
                    len(descendants)
                ).format(
                    n=len(descendants)
                )
                    len(descendants),
                ).format(n=len(descendants)),
            )

        else:
            messages.success(self.request, _('Article successfully moved!'))
            messages.success(self.request, _("Article successfully moved!"))
        return redirect("wiki:get", path=self.urlpath.path)




@@ 483,26 519,27 @@ class Deleted(Delete):
    @method_decorator(get_article(can_read=True, deleted_contents=True))
    def dispatch(self, request, article, *args, **kwargs):

        self.urlpath = kwargs.get('urlpath', None)
        self.urlpath = kwargs.get("urlpath", None)
        self.article = article

        if self.urlpath:
            deleted_ancestor = self.urlpath.first_deleted_ancestor()
            if deleted_ancestor is None:
                # No one is deleted!
                return redirect('wiki:get', path=self.urlpath.path)
                return redirect("wiki:get", path=self.urlpath.path)
            elif deleted_ancestor != self.urlpath:
                # An ancestor was deleted, so redirect to that deleted page
                return redirect('wiki:deleted', path=deleted_ancestor.path)
                return redirect("wiki:deleted", path=deleted_ancestor.path)

        else:
            if not article.current_revision.deleted:
                return redirect('wiki:get', article_id=article.id)
                return redirect("wiki:get", article_id=article.id)

        # Restore
        if request.GET.get('restore', False):
        if request.GET.get("restore", False):
            can_restore = not article.current_revision.locked and article.can_delete(
                request.user)
                request.user
            )
            can_restore = can_restore or article.can_moderate(request.user)

            if can_restore:


@@ 510,28 547,29 @@ class Deleted(Delete):
                revision.inherit_predecessor(self.article)
                revision.set_from_request(request)
                revision.deleted = False
                revision.automatic_log = _('Restoring article')
                revision.automatic_log = _("Restoring article")
                self.article.add_revision(revision)
                messages.success(
                    request,
                    _('The article "%s" and its children are now restored.') %
                    revision.title)
                    _('The article "%s" and its children are now restored.')
                    % revision.title,
                )
                if self.urlpath:
                    return redirect('wiki:get', path=self.urlpath.path)
                    return redirect("wiki:get", path=self.urlpath.path)
                else:
                    return redirect('wiki:get', article_id=article.id)
                    return redirect("wiki:get", article_id=article.id)

        return super().dispatch1(request, article, *args, **kwargs)

    def get_initial(self):
        return {
            'revision': self.article.current_revision,
            'purge': True,
            "revision": self.article.current_revision,
            "purge": True,
        }

    def get_context_data(self, **kwargs):
        kwargs['purge_form'] = self.get_form()
        kwargs['form'] = kwargs['purge_form']
        kwargs["purge_form"] = self.get_form()
        kwargs["form"] = kwargs["purge_form"]
        return super().get_context_data(**kwargs)




@@ 543,7 581,7 @@ class Source(ArticleMixin, TemplateView):
        return super().dispatch(request, article, *args, **kwargs)

    def get_context_data(self, **kwargs):
        kwargs['selected_tab'] = 'source'
        kwargs["selected_tab"] = "source"
        return super().get_context_data(**kwargs)




@@ 551,13 589,14 @@ class History(ListView, ArticleMixin):

    template_name = "wiki/history.html"
    allow_empty = True
    context_object_name = 'revisions'
    context_object_name = "revisions"
    paginator_class = WikiPaginator
    paginate_by = 10

    def get_queryset(self):
        return models.ArticleRevision.objects.filter(
            article=self.article).order_by('-created')
        return models.ArticleRevision.objects.filter(article=self.article).order_by(
            "-created"
        )

    def get_context_data(self, **kwargs):
        # Is this a bit of a hack? Use better inheritance?


@@ 565,7 604,7 @@ class History(ListView, ArticleMixin):
        kwargs_listview = ListView.get_context_data(self, **kwargs)
        kwargs.update(kwargs_article)
        kwargs.update(kwargs_listview)
        kwargs['selected_tab'] = 'history'
        kwargs["selected_tab"] = "history"
        return kwargs

    @method_decorator(get_article(can_read=True))


@@ 577,7 616,7 @@ class Dir(ListView, ArticleMixin):

    template_name = "wiki/dir.html"
    allow_empty = True
    context_object_name = 'directory'
    context_object_name = "directory"
    model = models.URLPath
    paginator_class = WikiPaginator
    paginate_by = 30


@@ 586,7 625,7 @@ class Dir(ListView, ArticleMixin):
    def dispatch(self, request, article, *args, **kwargs):
        self.filter_form = forms.DirFilterForm(request.GET)
        if self.filter_form.is_valid():
            self.query = self.filter_form.cleaned_data['query']
            self.query = self.filter_form.cleaned_data["query"]
        else:
            self.query = None
        return super().dispatch(request, article, *args, **kwargs)


@@ 595,12 634,14 @@ class Dir(ListView, ArticleMixin):
        children = self.urlpath.get_children().can_read(self.request.user)
        if self.query:
            children = children.filter(
                Q(article__current_revision__title__icontains=self.query) | Q(slug__icontains=self.query)
                Q(article__current_revision__title__icontains=self.query)
                | Q(slug__icontains=self.query)
            )
        if not self.article.can_moderate(self.request.user):
            children = children.active()
        children = children.select_related_common().order_by(
            'article__current_revision__title')
            "article__current_revision__title"
        )
        return children

    def get_context_data(self, **kwargs):


@@ 608,8 649,8 @@ class Dir(ListView, ArticleMixin):
        kwargs_listview = ListView.get_context_data(self, **kwargs)
        kwargs.update(kwargs_article)
        kwargs.update(kwargs_listview)
        kwargs['filter_query'] = self.query
        kwargs['filter_form'] = self.filter_form
        kwargs["filter_query"] = self.query
        kwargs["filter_form"] = self.filter_form

        # Update each child's ancestor cache so the lookups don't have
        # to be repeated.


@@ 635,47 676,48 @@ class SearchView(ListView):
            return redirect(settings.LOGIN_URL)
        self.search_form = forms.SearchForm(request.GET)
        if self.search_form.is_valid():
            self.query = self.search_form.cleaned_data['q']
            self.query = self.search_form.cleaned_data["q"]
        else:
            self.query = None
        return super().dispatch(request, *args, **kwargs)

    def get_queryset(self):
        if not self.query:
            return models.Article.objects.none().order_by('-current_revision__created')
            return models.Article.objects.none().order_by("-current_revision__created")
        articles = models.Article.objects
        path = self.kwargs.get('path', None)
        path = self.kwargs.get("path", None)
        if path:
            try:
                self.urlpath = models.URLPath.get_by_path(path)
                article_ids = self.urlpath.get_descendants(
                    include_self=True).values_list('article_id')
                    include_self=True
                ).values_list("article_id")
                articles = articles.filter(id__in=article_ids)
            except (NoRootURL, models.URLPath.DoesNotExist):
                raise Http404
        articles = articles.filter(
            Q(current_revision__title__icontains=self.query) | Q(current_revision__content__icontains=self.query)
            Q(current_revision__title__icontains=self.query)
            | Q(current_revision__content__icontains=self.query)
        )
        if not permissions.can_moderate(
                models.URLPath.root().article,
                self.request.user):
            models.URLPath.root().article, self.request.user
        ):
            articles = articles.active().can_read(self.request.user)
        return articles.order_by('-current_revision__created')
        return articles.order_by("-current_revision__created")

    def get_context_data(self, **kwargs):
        kwargs = super().get_context_data(**kwargs)
        kwargs['search_form'] = self.search_form
        kwargs['search_query'] = self.query
        kwargs['urlpath'] = self.urlpath
        kwargs["search_form"] = self.search_form
        kwargs["search_query"] = self.query
        kwargs["urlpath"] = self.urlpath
        return kwargs


class Plugin(View):

    def dispatch(self, request, path=None, slug=None, **kwargs):
        kwargs['path'] = path
        kwargs["path"] = path
        for plugin in list(plugin_registry.get_plugins().values()):
            if getattr(plugin, 'slug', None) == slug:
            if getattr(plugin, "slug", None) == slug:
                return plugin.article_view(request, **kwargs)
        raise Http404()



@@ 704,14 746,14 @@ class Settings(ArticleMixin, TemplateView):
            # TODO: Do not set an attribute on a form class - this
            # could be mixed up with a different instance
            # Use strategy from Edit view...
            setattr(settings_forms[i], 'action', 'form%d' % i)
            setattr(settings_forms[i], "action", "form%d" % i)

        return settings_forms

    def post(self, *args, **kwargs):
        self.forms = []
        for form_class in self.get_form_classes():
            if form_class.action == self.request.GET.get('f', None):
            if form_class.action == self.request.GET.get("f", None):
                form = form_class(self.article, self.request, self.request.POST)
                if form.is_valid():
                    form.save()


@@ 719,8 761,8 @@ class Settings(ArticleMixin, TemplateView):
                    if usermessage:
                        messages.success(self.request, usermessage)
                    if self.urlpath:
                        return redirect('wiki:settings', path=self.urlpath.path)
                    return redirect('wiki:settings', article_id=self.article.id)
                        return redirect("wiki:settings", path=self.urlpath.path)
                    return redirect("wiki:settings", article_id=self.article.id)
            else:
                form = form_class(self.article, self.request)
            self.forms.append(form)


@@ 740,12 782,12 @@ class Settings(ArticleMixin, TemplateView):

    def get_success_url(self):
        if self.urlpath:
            return redirect('wiki:settings', path=self.urlpath.path)
        return redirect('wiki:settings', article_id=self.article.id)
            return redirect("wiki:settings", path=self.urlpath.path)
        return redirect("wiki:settings", article_id=self.article.id)

    def get_context_data(self, **kwargs):
        kwargs['selected_tab'] = 'settings'
        kwargs['forms'] = self.forms
        kwargs["selected_tab"] = "settings"
        kwargs["forms"] = self.forms
        return super().get_context_data(**kwargs)




@@ 756,30 798,30 @@ class ChangeRevisionView(RedirectView):
    @method_decorator(get_article(can_write=True, not_locked=True))
    def dispatch(self, request, article, *args, **kwargs):
        self.article = article
        self.urlpath = kwargs.pop('kwargs', False)
        self.urlpath = kwargs.pop("kwargs", False)
        self.change_revision()

        return super().dispatch(request, *args, **kwargs)

    def get_redirect_url(self, **kwargs):
        if self.urlpath:
            return reverse("wiki:history", kwargs={'path': self.urlpath.path})
            return reverse("wiki:history", kwargs={"path": self.urlpath.path})
        else:
            return reverse('wiki:history', kwargs={'article_id': self.article.id})
            return reverse("wiki:history", kwargs={"article_id": self.article.id})

    def change_revision(self):
        revision = get_object_or_404(
            models.ArticleRevision,
            article=self.article,
            id=self.kwargs['revision_id'])
            models.ArticleRevision, article=self.article, id=self.kwargs["revision_id"]
        )
        self.article.current_revision = revision
        self.article.save()
        messages.success(
            self.request,
            _("The article %(title)s is now set to display revision #%(revision_number)d") % {
                'title': revision.title,
                'revision_number': revision.revision_number,
            })
            _(
                "The article %(title)s is now set to display revision #%(revision_number)d"
            )
            % {"title": revision.title, "revision_number": revision.revision_number,},
        )


class Preview(ArticleMixin, TemplateView):


@@ 789,7 831,7 @@ class Preview(ArticleMixin, TemplateView):
    @method_decorator(xframe_options_sameorigin)
    @method_decorator(get_article(can_read=True, deleted_contents=True))
    def dispatch(self, request, article, *args, **kwargs):
        revision_id = request.GET.get('r', None)
        revision_id = request.GET.get("r", None)
        self.title = None
        self.content = None
        self.preview = False


@@ 801,19 843,19 @@ class Preview(ArticleMixin, TemplateView):
                # querystring
                raise Http404()
            self.revision = get_object_or_404(
                models.ArticleRevision,
                article=article,
                id=revision_id
                models.ArticleRevision, article=article, id=revision_id
            )
        else:
            self.revision = None
        return super().dispatch(request, article, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        edit_form = forms.EditForm(request, self.article.current_revision, request.POST, preview=True)
        edit_form = forms.EditForm(
            request, self.article.current_revision, request.POST, preview=True
        )
        if edit_form.is_valid():
            self.title = edit_form.cleaned_data['title']
            self.content = edit_form.cleaned_data['content']
            self.title = edit_form.cleaned_data["title"]
            self.content = edit_form.cleaned_data["content"]
            self.preview = True
        return super().get(request, *args, **kwargs)



@@ 825,16 867,16 @@ class Preview(ArticleMixin, TemplateView):
        return super().get(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        kwargs['title'] = self.title
        kwargs['revision'] = self.revision
        kwargs['content'] = self.content
        kwargs['preview'] = self.preview
        kwargs["title"] = self.title
        kwargs["revision"] = self.revision
        kwargs["content"] = self.content
        kwargs["preview"] = self.preview
        return ArticleMixin.get_context_data(self, **kwargs)


class DiffView(DetailView):
    model = models.ArticleRevision
    pk_url_kwarg = 'revision_id'
    pk_url_kwarg = "revision_id"

    def render_to_response(self, context, **response_kwargs):
        revision = self.get_object()


@@ 850,10 892,10 @@ class DiffView(DetailView):
        other_changes = []

        if not other_revision or other_revision.title != revision.title:
            other_changes.append((_('New title'), revision.title))
            other_changes.append((_("New title"), revision.title))

        return object_to_json_response(
            {'diff': list(diff), 'other_changes': other_changes}
            {"diff": list(diff), "other_changes": other_changes}
        )




@@ 869,11 911,12 @@ class MergeView(View):

    def get(self, request, article, revision_id, *args, **kwargs):
        revision = get_object_or_404(
            models.ArticleRevision,
            article=article,
            id=revision_id)
            models.ArticleRevision, article=article, id=revision_id
        )

        current_text = article.current_revision.content if article.current_revision else ""
        current_text = (
            article.current_revision.content if article.current_revision else ""
        )
        new_text = revision.content

        content = simple_merge(current_text, new_text)


@@ 884,9 927,9 @@ class MergeView(View):

            if revision.deleted:
                c = {
                    'error_msg': _('You cannot merge with a deleted revision'),
                    'article': article,
                    'urlpath': self.urlpath
                    "error_msg": _("You cannot merge with a deleted revision"),
                    "article": article,
                    "urlpath": self.urlpath,
                }
                return render(request, self.template_error_name, context=c)



@@ 896,41 939,41 @@ class MergeView(View):
            new_revision.locked = False
            new_revision.title = article.current_revision.title
            new_revision.content = content
            new_revision.automatic_log = (
                _('Merge between revision #%(r1)d and revision #%(r2)d') % {
                    'r1': revision.revision_number,
                    'r2': old_revision.revision_number})
            new_revision.automatic_log = _(
                "Merge between revision #%(r1)d and revision #%(r2)d"
            ) % {"r1": revision.revision_number, "r2": old_revision.revision_number}
            article.add_revision(new_revision, save=True)

            old_revision.simpleplugin_set.all().update(
                article_revision=new_revision)
            old_revision.simpleplugin_set.all().update(article_revision=new_revision)
            revision.simpleplugin_set.all().update(article_revision=new_revision)

            messages.success(
                request,
                _('A new revision was created: Merge between revision #%(r1)d and revision #%(r2)d') % {
                    'r1': revision.revision_number,
                    'r2': old_revision.revision_number})
                _(
                    "A new revision was created: Merge between revision #%(r1)d and revision #%(r2)d"
                )
                % {"r1": revision.revision_number, "r2": old_revision.revision_number},
            )
            if self.urlpath:
                return redirect('wiki:edit', path=self.urlpath.path)
                return redirect("wiki:edit", path=self.urlpath.path)
            else:
                return redirect('wiki:edit', article_id=article.id)
                return redirect("wiki:edit", article_id=article.id)

        c = {
            'article': article,
            'title': article.current_revision.title,
            'revision': None,
            'merge1': revision,
            'merge2': article.current_revision,
            'merge': True,
            'content': content
            "article": article,
            "title": article.current_revision.title,
            "revision": None,
            "merge1": revision,
            "merge2": article.current_revision,
            "merge": True,
            "content": content,
        }
        return render(request, self.template_name, c)


class CreateRootView(FormView):
    form_class = forms.CreateRootForm
    template_name = 'wiki/create_root.html'
    template_name = "wiki/create_root.html"

    def dispatch(self, request, *args, **kwargs):
        if not request.user.is_superuser:


@@ 942,7 985,7 @@ class CreateRootView(FormView):
            pass
        else:
            if root.article:
                return redirect('wiki:get', path=root.path)
                return redirect("wiki:get", path=root.path)

            # TODO: This is too dangerous... let's say there is no root.article and we end up here,
            # then it might cascade to delete a lot of things on an existing


@@ 954,15 997,15 @@ class CreateRootView(FormView):
        models.URLPath.create_root(
            title=form.cleaned_data["title"],
            content=form.cleaned_data["content"],
            request=self.request
            request=self.request,
        )
        return redirect("wiki:root")

    def get_context_data(self, **kwargs):
        kwargs = super().get_context_data(**kwargs)
        kwargs['editor'] = editors.getEditor()
        kwargs["editor"] = editors.getEditor()
        return kwargs


class MissingRootView(TemplateView):
    template_name = 'wiki/root_missing.html'
    template_name = "wiki/root_missing.html"

M src/wiki/views/deleted_list.py => src/wiki/views/deleted_list.py +2 -2
@@ 10,7 10,7 @@ class DeletedListView(TemplateView):
    def dispatch(self, request, *args, **kwargs):
        # Let logged in super users continue
        if not request.user.is_superuser:
            return redirect('wiki:root')
            return redirect("wiki:root")

        return super().dispatch(request, *args, **kwargs)



@@ 20,5 20,5 @@ class DeletedListView(TemplateView):
        for article in article_list:
            if article.current_revision.deleted:
                deleted_articles.append(article)
        kwargs['deleted_articles'] = deleted_articles
        kwargs["deleted_articles"] = deleted_articles
        return super().get_context_data(**kwargs)

M src/wiki/views/mixins.py => src/wiki/views/mixins.py +14 -13
@@ 14,29 14,30 @@ class ArticleMixin(TemplateResponseMixin):
    template context."""

    def dispatch(self, request, article, *args, **kwargs):
        self.urlpath = kwargs.pop('urlpath', None)
        self.urlpath = kwargs.pop("urlpath", None)
        self.article = article
        self.children_slice = []
        if settings.SHOW_MAX_CHILDREN > 0:
            try:
                for child in self.article.get_children(
                        max_num=settings.SHOW_MAX_CHILDREN +
                        1,
                        articles__article__current_revision__deleted=False,
                        user_can_read=request.user):
                    max_num=settings.SHOW_MAX_CHILDREN + 1,
                    articles__article__current_revision__deleted=False,
                    user_can_read=request.user,
                ):
                    self.children_slice.append(child)
            except AttributeError as e:
                log.error(
                    "Attribute error most likely caused by wrong MPTT version. Use 0.5.3+.\n\n" +
                    str(e))
                    "Attribute error most likely caused by wrong MPTT version. Use 0.5.3+.\n\n"
                    + str(e)
                )
                raise
        return super().dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        kwargs['urlpath'] = self.urlpath
        kwargs['article'] = self.article
        kwargs['article_tabs'] = registry.get_article_tabs()
        kwargs['children_slice'] = self.children_slice[:20]
        kwargs['children_slice_more'] = len(self.children_slice) > 20
        kwargs['plugins'] = registry.get_plugins()
        kwargs["urlpath"] = self.urlpath
        kwargs["article"] = self.article
        kwargs["article_tabs"] = registry.get_article_tabs()
        kwargs["children_slice"] = self.children_slice[:20]
        kwargs["children_slice_more"] = len(self.children_slice) > 20
        kwargs["plugins"] = registry.get_plugins()
        return kwargs

M testproject/testproject/settings/base.py => testproject/testproject/settings/base.py +47 -55
@@ 19,7 19,7 @@ BASE_DIR = os.path.dirname(PROJECT_DIR)
# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'b^fv_)t39h%9p40)fnkfblo##jkr!$0)lkp6bpy!fi*f$4*92!'
SECRET_KEY = "b^fv_)t39h%9p40)fnkfblo##jkr!$0)lkp6bpy!fi*f$4*92!"

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False


@@ 28,54 28,52 @@ ALLOWED_HOSTS = []


INSTALLED_APPS = [
    'django.contrib.humanize.apps.HumanizeConfig',
    'django.contrib.auth.apps.AuthConfig',
    'django.contrib.contenttypes.apps.ContentTypesConfig',
    'django.contrib.sessions.apps.SessionsConfig',
    'django.contrib.sites.apps.SitesConfig',
    'django.contrib.messages.apps.MessagesConfig',
    'django.contrib.staticfiles.apps.StaticFilesConfig',
    'django.contrib.admin.apps.AdminConfig',
    'django.contrib.admindocs.apps.AdminDocsConfig',
    'sekizai',
    'sorl.thumbnail',
    "django.contrib.humanize.apps.HumanizeConfig",
    "django.contrib.auth.apps.AuthConfig",
    "django.contrib.contenttypes.apps.ContentTypesConfig",
    "django.contrib.sessions.apps.SessionsConfig",
    "django.contrib.sites.apps.SitesConfig",
    "django.contrib.messages.apps.MessagesConfig",
    "django.contrib.staticfiles.apps.StaticFilesConfig",
    "django.contrib.admin.apps.AdminConfig",
    "django.contrib.admindocs.apps.AdminDocsConfig",
    "sekizai",
    "sorl.thumbnail",
    "django_nyt.apps.DjangoNytConfig",
    "wiki.apps.WikiConfig",
    "wiki.plugins.macros.apps.MacrosConfig",
    'wiki.plugins.help.apps.HelpConfig',
    'wiki.plugins.links.apps.LinksConfig',
    "wiki.plugins.help.apps.HelpConfig",
    "wiki.plugins.links.apps.LinksConfig",
    "wiki.plugins.images.apps.ImagesConfig",
    "wiki.plugins.attachments.apps.AttachmentsConfig",
    "wiki.plugins.notifications.apps.NotificationsConfig",
    'wiki.plugins.editsection.apps.EditSectionConfig',
    'wiki.plugins.globalhistory.apps.GlobalHistoryConfig',
    'mptt',
    "wiki.plugins.editsection.apps.EditSectionConfig",
    "wiki.plugins.globalhistory.apps.GlobalHistoryConfig",
    "mptt",
]

TEST_RUNNER = 'django.test.runner.DiscoverRunner'
TEST_RUNNER = "django.test.runner.DiscoverRunner"


MIDDLEWARE = [
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'django.middleware.security.SecurityMiddleware',
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "django.middleware.security.SecurityMiddleware",
]

ROOT_URLCONF = 'testproject.urls'
ROOT_URLCONF = "testproject.urls"

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(PROJECT_DIR, 'templates'),
        ],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(PROJECT_DIR, "templates"),],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.contrib.auth.context_processors.auth",
                "django.template.context_processors.debug",
                "django.template.context_processors.i18n",


@@ 84,23 82,23 @@ TEMPLATES = [
                "django.contrib.messages.context_processors.messages",
                "sekizai.context_processors.sekizai",
            ],
            'debug': DEBUG,
            "debug": DEBUG,
        },
    },
]

WSGI_APPLICATION = 'testproject.wsgi.application'
WSGI_APPLICATION = "testproject.wsgi.application"


LOGIN_REDIRECT_URL = reverse_lazy('wiki:get', kwargs={'path': ''})
LOGIN_REDIRECT_URL = reverse_lazy("wiki:get", kwargs={"path": ""})


# Database
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(PROJECT_DIR, 'db', 'prepopulated.db'),
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": os.path.join(PROJECT_DIR, "db", "prepopulated.db"),
    }
}



@@ 109,27 107,21 @@ DATABASES = {

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
    },
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",},
]

# Internationalization
# https://docs.djangoproject.com/en/1.9/topics/i18n/

TIME_ZONE = 'Europe/Berlin'
TIME_ZONE = "Europe/Berlin"

# Language code for this installation. All choices can be found here:
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = 'en-US'
LANGUAGE_CODE = "en-US"

SITE_ID = 1



@@ 143,10 135,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.9/howto/static-files/

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(PROJECT_DIR, 'static')
MEDIA_ROOT = os.path.join(PROJECT_DIR, 'media')
MEDIA_URL = '/media/'
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(PROJECT_DIR, "static")
MEDIA_ROOT = os.path.join(PROJECT_DIR, "media")
MEDIA_URL = "/media/"


WIKI_ANONYMOUS_WRITE = True

M testproject/testproject/settings/codehilite.py => testproject/testproject/settings/codehilite.py +2 -7
@@ 4,10 4,5 @@ from testproject.settings.local import *
# Test codehilite with pygments

WIKI_MARKDOWN_KWARGS = {
    'extensions': [
        'codehilite',
        'footnotes',
        'attr_list',
        'headerid',
        'extra',
    ]}
    "extensions": ["codehilite", "footnotes", "attr_list", "headerid", "extra",]
}

M testproject/testproject/settings/customauthuser.py => testproject/testproject/settings/customauthuser.py +5 -5
@@ 3,17 3,17 @@ import os  # noqa @UnusedImport
from .base import *  # noqa @UnusedWildImport

DATABASES = {
    'default': {
    "default": {
        # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
        'ENGINE': 'django.db.backends.sqlite3',
        "ENGINE": "django.db.backends.sqlite3",
        # Or path to database file if using sqlite3.
        'NAME': os.path.join(PROJECT_DIR, 'db', 'prepopulated-customauthuser.db'),
        "NAME": os.path.join(PROJECT_DIR, "db", "prepopulated-customauthuser.db"),
    }
}

INSTALLED_APPS = PROJECT_DIR + [
    # Test application for testing custom users
    'wiki.tests.testdata',
    "wiki.tests.testdata",
]

AUTH_USER_MODEL = 'testdata.CustomUser'
AUTH_USER_MODEL = "testdata.CustomUser"

M testproject/testproject/settings/dev.py => testproject/testproject/settings/dev.py +7 -6
@@ 4,19 4,20 @@ DEBUG = True


for template_engine in TEMPLATES:
    template_engine['OPTIONS']['debug'] = True
    template_engine["OPTIONS"]["debug"] = True


EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"


try:
    import debug_toolbar  # @UnusedImport

    MIDDLEWARE = list(MIDDLEWARE) + [
        'debug_toolbar.middleware.DebugToolbarMiddleware',
        "debug_toolbar.middleware.DebugToolbarMiddleware",
    ]
    INSTALLED_APPS = list(INSTALLED_APPS) + ['debug_toolbar']
    INTERNAL_IPS = ('127.0.0.1',)
    DEBUG_TOOLBAR_CONFIG = {'INTERCEPT_REDIRECTS': False}
    INSTALLED_APPS = list(INSTALLED_APPS) + ["debug_toolbar"]
    INTERNAL_IPS = ("127.0.0.1",)
    DEBUG_TOOLBAR_CONFIG = {"INTERCEPT_REDIRECTS": False}
except ImportError:
    pass

M testproject/testproject/settings/sendfile.py => testproject/testproject/settings/sendfile.py +2 -2
@@ 1,10 1,10 @@
from .base import *  # noqa @UnusedWildImport

INSTALLED_APPS += ['sendfile']
INSTALLED_APPS += ["sendfile"]

WIKI_ATTACHMENTS_USE_SENDFILE = True


SENDFILE_BACKEND = 'sendfile.backends.development'
SENDFILE_BACKEND = "sendfile.backends.development"
# SENDFILE_URL = None #Not needed
# SENDFILE_ROOT = None #Not needed

M testproject/testproject/urls.py => testproject/testproject/urls.py +11 -7
@@ 8,21 8,25 @@ from django.urls import include, re_path
admin.autodiscover()

urlpatterns = [
    re_path(r'^admin/', admin.site.urls),
    re_path(r'^robots.txt', lambda _: HttpResponse('User-agent: *\nDisallow: /')),
    re_path(r"^admin/", admin.site.urls),
    re_path(r"^robots.txt", lambda _: HttpResponse("User-agent: *\nDisallow: /")),
]

if settings.DEBUG:
    urlpatterns += staticfiles_urlpatterns()
    urlpatterns += [
        re_path(r'^media/(?P<path>.*)$', static_serve, {'document_root': settings.MEDIA_ROOT}),
        re_path(
            r"^media/(?P<path>.*)$",
            static_serve,
            {"document_root": settings.MEDIA_ROOT},
        ),
    ]


urlpatterns += [
    re_path(r'^notify/', include('django_nyt.urls')),
    re_path(r'', include('wiki.urls')),
    re_path(r"^notify/", include("django_nyt.urls")),
    re_path(r"", include("wiki.urls")),
]

handler500 = 'testproject.views.server_error'
handler404 = 'testproject.views.page_not_found'
handler500 = "testproject.views.server_error"
handler404 = "testproject.views.page_not_found"

M testproject/testproject/views.py => testproject/testproject/views.py +15 -15
@@ 6,24 6,24 @@ from django.views.decorators.csrf import requires_csrf_token


@requires_csrf_token
def server_error(request, template_name='500.html', **param_dict):
def server_error(request, template_name="500.html", **param_dict):
    # You need to create a 500.html template.
    t = loader.get_template(template_name)
    return HttpResponseServerError(t.render(RequestContext(
        request,
        {
            'MEDIA_URL': settings.MEDIA_URL,
            'STATIC_URL': settings.STATIC_URL,
            'request': request,
        },
    )))
    return HttpResponseServerError(
        t.render(
            RequestContext(
                request,
                {
                    "MEDIA_URL": settings.MEDIA_URL,
                    "STATIC_URL": settings.STATIC_URL,
                    "request": request,
                },
            )
        )
    )


def page_not_found(request, template_name='404.html', exception=None):
    response = server_error(
        request,
        template_name=template_name,
        exception=exception
    )
def page_not_found(request, template_name="404.html", exception=None):
    response = server_error(request, template_name=template_name, exception=exception)
    response.status_code = 404
    return response

M tests/base.py => tests/base.py +14 -14
@@ 8,22 8,20 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from wiki.models import URLPath

SUPERUSER1_USERNAME = 'admin'
SUPERUSER1_PASSWORD = 'secret'
SUPERUSER1_USERNAME = "admin"
SUPERUSER1_PASSWORD = "secret"


class RequireSuperuserMixin:

    def setUp(self):
        super().setUp()

        from django.contrib.auth import get_user_model

        User = get_user_model()

        self.superuser1 = User.objects.create_superuser(
            SUPERUSER1_USERNAME,
            'nobody@example.com',
            SUPERUSER1_PASSWORD
            SUPERUSER1_USERNAME, "nobody@example.com", SUPERUSER1_PASSWORD
        )




@@ 31,6 29,7 @@ class RequireBasicData(RequireSuperuserMixin):
    """
    Mixin that creates common data required for all tests.
    """

    pass




@@ 39,7 38,6 @@ class TestBase(RequireBasicData, TestCase):


class RequireRootArticleMixin:

    def setUp(self):
        super().setUp()
        self.root = URLPath.create_root()


@@ 54,6 52,7 @@ class ArticleTestBase(RequireRootArticleMixin, TestBase):
    """
    Sets up basic data for testing with an article and some revisions
    """

    pass




@@ 68,6 67,7 @@ class WebTestCommonMixin(RequireBasicData, django_functest.ShortcutLoginMixin):
    """
    Common setup required for WebTest and Selenium tests
    """

    def setUp(self):
        super().setUp()



@@ 78,13 78,15 @@ class WebTestBase(WebTestCommonMixin, django_functest.FuncWebTestMixin, TestCase
    pass


INCLUDE_SELENIUM_TESTS = os.environ.get('INCLUDE_SELENIUM_TESTS', '0') == '1'
INCLUDE_SELENIUM_TESTS = os.environ.get("INCLUDE_SELENIUM_TESTS", "0") == "1"


@unittest.skipUnless(INCLUDE_SELENIUM_TESTS, "Skipping Selenium tests")
class SeleniumBase(WebTestCommonMixin, django_functest.FuncSeleniumMixin, StaticLiveServerTestCase):
class SeleniumBase(
    WebTestCommonMixin, django_functest.FuncSeleniumMixin, StaticLiveServerTestCase
):
    driver_name = "Chrome"
    display = os.environ.get('SELENIUM_SHOW_BROWSER', '0') == '1'
    display = os.environ.get("SELENIUM_SHOW_BROWSER", "0") == "1"

    if not INCLUDE_SELENIUM_TESTS:
        # Don't call super() in setUpClass(), it will attempt to instantiate


@@ 99,17 101,15 @@ class SeleniumBase(WebTestCommonMixin, django_functest.FuncSeleniumMixin, Static


class ArticleWebTestUtils:

    def get_by_path(self, path):
        """
        Get the article response for the path.
        Example:  self.get_by_path("Level1/Slug2/").title
        """
        return self.client.get(reverse('wiki:get', kwargs={'path': path}))
        return self.client.get(reverse("wiki:get", kwargs={"path": path}))


class TemplateTestCase(TestCase):

    @property
    def template(self):
        raise NotImplementedError("Subclasses must implement this")


@@ 121,7 121,6 @@ class TemplateTestCase(TestCase):
# See
# https://github.com/django-wiki/django-wiki/pull/382
class wiki_override_settings(override_settings):

    def enable(self):
        super().enable()
        self.reload_wiki_settings()


@@ 133,4 132,5 @@ class wiki_override_settings(override_settings):
    def reload_wiki_settings(self):
        from importlib import reload
        from wiki.conf import settings

        reload(settings)

M tests/core/test_accounts.py => tests/core/test_accounts.py +37 -38
@@ 7,49 7,48 @@ from wiki.conf import settings as wiki_settings
from wiki.models import reverse

from ..base import (
    SUPERUSER1_PASSWORD, SUPERUSER1_USERNAME, ArticleWebTestUtils, DjangoClientTestBase, RequireRootArticleMixin, TestBase, wiki_override_settings,
    SUPERUSER1_PASSWORD,
    SUPERUSER1_USERNAME,
    ArticleWebTestUtils,
    DjangoClientTestBase,
    RequireRootArticleMixin,
    TestBase,
    wiki_override_settings,
)
from ..testdata.models import CustomUser


class AccountUpdateTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class AccountUpdateTest(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_password_change(self):
        """
        Test that we can make a successful password change via the update form
        """
        # Check out that it works as expected, notice that there is no referrer
        # on this GET request.
        self.client.get(
            resolve_url('wiki:profile_update',)
        )
        self.client.get(resolve_url("wiki:profile_update",))

        # Now check that we don't succeed with unmatching passwords
        example_data = {
            'password1': 'abcdef',
            'password2': 'abcdef123',
            'email': self.superuser1.email,
            "password1": "abcdef",
            "password2": "abcdef123",
            "email": self.superuser1.email,
        }

        # save a new revision
        response = self.client.post(
            resolve_url('wiki:profile_update'),
            example_data
        )
        response = self.client.post(resolve_url("wiki:profile_update"), example_data)
        self.assertContains(response, "Passwords don&#39;t match", status_code=200)

        # Now check that we don't succeed with unmatching passwords
        example_data = {
            'password1': 'abcdef',
            'password2': 'abcdef',
            'email': self.superuser1.email,
            "password1": "abcdef",
            "password2": "abcdef",
            "email": self.superuser1.email,
        }

        # save a new revision
        response = self.client.post(
            resolve_url('wiki:profile_update'),
            example_data
        )
        response = self.client.post(resolve_url("wiki:profile_update"), example_data)

        # Need to force str() because of:
        # TypeError: coercing to Unicode: need string or buffer, __proxy__


@@ 59,30 58,29 @@ class AccountUpdateTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClie
        self.assertEqual(
            self.superuser1,
            authenticate(
                username=self.superuser1.username,
                password=example_data['password1']
            )
                username=self.superuser1.username, password=example_data["password1"]
            ),
        )


class UpdateProfileViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class UpdateProfileViewTest(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_update_profile(self):
        self.client.post(
            resolve_url('wiki:profile_update'),
            resolve_url("wiki:profile_update"),
            {"email": "test@test.com", "password1": "newPass", "password2": "newPass"},
            follow=True
            follow=True,
        )

        test_auth = authenticate(username='admin', password='newPass')
        test_auth = authenticate(username="admin", password="newPass")

        self.assertNotEqual(test_auth, None)
        self.assertEqual(test_auth.email, 'test@test.com')
        self.assertEqual(test_auth.email, "test@test.com")


@wiki_override_settings(ACCOUNT_HANDLING=True)
class LogoutViewTests(RequireRootArticleMixin, DjangoClientTestBase):

    def test_logout_account_handling(self):
        self.client.get(wiki_settings.LOGOUT_URL)
        user = auth.get_user(self.client)


@@ 95,27 93,28 @@ class LoginTestViews(RequireRootArticleMixin, TestBase):
    def test_already_signed_in(self):
        self.client.force_login(self.superuser1)
        response = self.client.get(wiki_settings.LOGIN_URL)
        self.assertRedirects(response, reverse('wiki:root'))
        self.assertRedirects(response, reverse("wiki:root"))

    def test_log_in(self):
        self.client.post(
            wiki_settings.LOGIN_URL,
            {'username': SUPERUSER1_USERNAME, 'password': SUPERUSER1_PASSWORD}
            {"username": SUPERUSER1_USERNAME, "password": SUPERUSER1_PASSWORD},
        )
        self.assertIs(self.superuser1.is_authenticated, True)
        self.assertEqual(auth.get_user(self.client), self.superuser1)


class SignupViewTests(RequireRootArticleMixin, TestBase):

    @wiki_override_settings(ACCOUNT_HANDLING=True, ACCOUNT_SIGNUP_ALLOWED=True)
    def test_signup(self):
        response = self.client.post(
            wiki_settings.SIGNUP_URL,
            data={
                'password1': 'wiki', 'password2': 'wiki', 'username': 'wiki',
                'email': 'wiki@wiki.com'
            }
                "password1": "wiki",
                "password2": "wiki",
                "username": "wiki",
                "email": "wiki@wiki.com",
            },
        )
        self.assertIs(CustomUser.objects.filter(email='wiki@wiki.com').exists(), True)
        self.assertRedirects(response, reverse('wiki:login'))
        self.assertIs(CustomUser.objects.filter(email="wiki@wiki.com").exists(), True)
        self.assertRedirects(response, reverse("wiki:login"))

M tests/core/test_basic.py => tests/core/test_basic.py +12 -8
@@ 12,7 12,6 @@ from ..testdata.models import CustomGroup


class URLPathTests(TestCase):

    def test_manager(self):

        root = URLPath.create_root()


@@ 23,22 22,22 @@ class URLPathTests(TestCase):


class CustomGroupTests(TestCase):
    @wiki_override_settings(WIKI_GROUP_MODEL='auth.Group')
    @wiki_override_settings(WIKI_GROUP_MODEL="auth.Group")
    def test_setting(self):
        self.assertEqual(wiki_settings.GROUP_MODEL, 'auth.Group')
        self.assertEqual(wiki_settings.GROUP_MODEL, "auth.Group")

    def test_custom(self):
        self.assertEqual(Group, CustomGroup)
        self.assertEqual(wiki_settings.GROUP_MODEL, 'testdata.CustomGroup')
        self.assertEqual(wiki_settings.GROUP_MODEL, "testdata.CustomGroup")


class LineEndingsTests(TestCase):

    def test_manager(self):

        article = Article()
        article.add_revision(ArticleRevision(title="Root", content="Hello\nworld"),
                             save=True)
        article.add_revision(
            ArticleRevision(title="Root", content="Hello\nworld"), save=True
        )
        self.assertEqual("Hello\r\nworld", article.current_revision.content)




@@ 51,6 50,11 @@ class HttpTests(TestCase):
        assert "inline" in response.get("Content-Disposition")
        response = send_file(fabricate_request, fobject.name, filename="test.jpeg")
        assert response.has_header("Content-Disposition")
        response = send_file(fabricate_request, fobject.name, filename="test.jpeg", last_modified=datetime.now())
        response = send_file(
            fabricate_request,
            fobject.name,
            filename="test.jpeg",
            last_modified=datetime.now(),
        )
        assert response.has_header("Content-Disposition")
        fobject.close()

M tests/core/test_checks.py => tests/core/test_checks.py +37 -18
@@ 3,7 3,12 @@ import copy
from django.conf import settings
from django.core.checks import Error, registry
from django.test import TestCase
from wiki.checks import FIELDS_IN_CUSTOM_USER_MODEL, REQUIRED_CONTEXT_PROCESSORS, REQUIRED_INSTALLED_APPS, Tags
from wiki.checks import (
    FIELDS_IN_CUSTOM_USER_MODEL,
    REQUIRED_CONTEXT_PROCESSORS,
    REQUIRED_INSTALLED_APPS,
    Tags,
)

from ..base import wiki_override_settings



@@ 18,27 23,25 @@ class CheckTests(TestCase):
            with self.settings(INSTALLED_APPS=_remove(settings.INSTALLED_APPS, app[0])):
                errors = registry.run_checks(tags=[Tags.required_installed_apps])
                expected_errors = [
                    Error(
                        'needs %s in INSTALLED_APPS' % app[1],
                        id='wiki.%s' % app[2],
                    )
                    Error("needs %s in INSTALLED_APPS" % app[1], id="wiki.%s" % app[2],)
                ]
                self.assertEqual(errors, expected_errors)

    def test_required_context_processors(self):
        for context_processor in REQUIRED_CONTEXT_PROCESSORS:
            TEMPLATES = copy.deepcopy(settings.TEMPLATES)
            TEMPLATES[0]['OPTIONS']['context_processors'] = [
            TEMPLATES[0]["OPTIONS"]["context_processors"] = [
                cp
                for cp in TEMPLATES[0]['OPTIONS']['context_processors']
                for cp in TEMPLATES[0]["OPTIONS"]["context_processors"]
                if cp != context_processor[0]
            ]
            with self.settings(TEMPLATES=TEMPLATES):
                errors = registry.run_checks(tags=[Tags.context_processors])
                expected_errors = [
                    Error(
                        "needs %s in TEMPLATE['OPTIONS']['context_processors']" % context_processor[0],
                        id='wiki.%s' % context_processor[1],
                        "needs %s in TEMPLATE['OPTIONS']['context_processors']"
                        % context_processor[0],
                        id="wiki.%s" % context_processor[1],
                    )
                ]
                self.assertEqual(errors, expected_errors)


@@ 54,28 57,44 @@ class CheckTests(TestCase):
        from django.core.exceptions import FieldError
        from django import forms
        from ..testdata.models import VeryCustomUser
        with self.assertRaisesRegex(FieldError, 'Unknown field\\(s\\) \\((email|username|, )+\\) specified for VeryCustomUser'):

        with self.assertRaisesRegex(
            FieldError,
            "Unknown field\\(s\\) \\((email|username|, )+\\) specified for VeryCustomUser",
        ):

            class UserUpdateForm(forms.ModelForm):
                class Meta:
                    model = VeryCustomUser
                    fields = ['username', 'email']
                    fields = ["username", "email"]

    def test_check_for_fields_in_custom_user_model(self):
        from django.contrib.auth import get_user_model
        with wiki_override_settings(WIKI_ACCOUNT_HANDLING=False, AUTH_USER_MODEL='testdata.VeryCustomUser'):

        with wiki_override_settings(
            WIKI_ACCOUNT_HANDLING=False, AUTH_USER_MODEL="testdata.VeryCustomUser"
        ):
            errors = registry.run_checks(tags=[Tags.fields_in_custom_user_model])
            self.assertEqual(errors, [])
        with wiki_override_settings(WIKI_ACCOUNT_HANDLING=True, AUTH_USER_MODEL='testdata.VeryCustomUser'):
        with wiki_override_settings(
            WIKI_ACCOUNT_HANDLING=True, AUTH_USER_MODEL="testdata.VeryCustomUser"
        ):
            errors = registry.run_checks(tags=[Tags.fields_in_custom_user_model])
            expected_errors = [
                Error(
                    '%s.%s.%s refers to a field that is not of type %s' % (
                        get_user_model().__module__, get_user_model().__name__, field_fetcher, required_field_type),
                    hint='If you have your own login/logout views, turn off settings.WIKI_ACCOUNT_HANDLING',
                    "%s.%s.%s refers to a field that is not of type %s"
                    % (
                        get_user_model().__module__,
                        get_user_model().__name__,
                        field_fetcher,
                        required_field_type,
                    ),
                    hint="If you have your own login/logout views, turn off settings.WIKI_ACCOUNT_HANDLING",
                    obj=get_user_model(),
                    id='wiki.%s' % error_code,
                    id="wiki.%s" % error_code,
                )
                for check_function_name, field_fetcher, required_field_type, error_code in FIELDS_IN_CUSTOM_USER_MODEL]
                for check_function_name, field_fetcher, required_field_type, error_code in FIELDS_IN_CUSTOM_USER_MODEL
            ]
            self.assertEqual(errors, expected_errors)
        with wiki_override_settings(WIKI_ACCOUNT_HANDLING=True):
            errors = registry.run_checks(tags=[Tags.fields_in_custom_user_model])

M tests/core/test_commands.py => tests/core/test_commands.py +4 -4
@@ 17,12 17,12 @@ class TestManagementCommands(ArticleTestBase):
    def test_dumpdata_loaddata(self):

        sysout = sys.stdout
        fixtures_file = tempfile.NamedTemporaryFile('w', delete=False, suffix=".json")
        fixtures_file = tempfile.NamedTemporaryFile("w", delete=False, suffix=".json")
        sys.stdout = fixtures_file
        call_command('dumpdata', 'wiki')
        call_command("dumpdata", "wiki")
        fixtures_file.file.flush()
        fixtures_file.file.close()
        sys.stdout = open(os.devnull, 'w')
        call_command('loaddata', fixtures_file.name)
        sys.stdout = open(os.devnull, "w")
        call_command("loaddata", fixtures_file.name)
        sys.stdout = sysout
        os.unlink(fixtures_file.name)

M tests/core/test_forms.py => tests/core/test_forms.py +10 -6
@@ 6,21 6,25 @@ from wiki.forms import DeleteForm, UserCreationForm

class DeleteFormTests(RequireRootArticleMixin, DjangoClientTestBase):
    def test_not_sure(self):
        data = {'purge': True, 'confirm': False}
        data = {"purge": True, "confirm": False}
        form = DeleteForm(article=self.root_article, has_children=True, data=data)
        self.assertIs(form.is_valid(), False)
        self.assertEqual(form.errors['__all__'], [gettext('You are not sure enough!')])
        self.assertEqual(form.errors["__all__"], [gettext("You are not sure enough!")])


class UserCreationFormTests(TestCase):
    def test_honeypot(self):
        data = {
            'address': 'Wiki Road 123', 'phone': '12345678', 'email': 'wiki@wiki.com',
            'username': 'WikiMan', 'password1': 'R@ndomString', 'password2': 'R@ndomString'
            "address": "Wiki Road 123",
            "phone": "12345678",
            "email": "wiki@wiki.com",
            "username": "WikiMan",
            "password1": "R@ndomString",
            "password2": "R@ndomString",
        }
        form = UserCreationForm(data=data)
        self.assertIs(form.is_valid(), False)
        self.assertEqual(
            form.errors['__all__'],
            ["Thank you, non-human visitor. Please keep trying to fill in the form."]
            form.errors["__all__"],
            ["Thank you, non-human visitor. Please keep trying to fill in the form."],
        )

M tests/core/test_managers.py => tests/core/test_managers.py +12 -39
@@ 11,15 11,10 @@ from ..base import ArticleTestBase


class ArticleManagerTests(ArticleTestBase):

    def test_queryset_methods_directly_on_manager(self):

        self.assertEqual(
            Article.objects.can_read(self.superuser1).count(), 1
        )
        self.assertEqual(
            Article.objects.can_write(self.superuser1).count(), 1
        )
        self.assertEqual(Article.objects.can_read(self.superuser1).count(), 1)
        self.assertEqual(Article.objects.can_write(self.superuser1).count(), 1)
        self.assertEqual(Article.objects.active().count(), 1)

    def test_mass_deletion(self):


@@ 27,61 22,40 @@ class ArticleManagerTests(ArticleTestBase):
        https://github.com/django-wiki/django-wiki/issues/857
        """
        Article.objects.all().delete()
        self.assertEqual(
            Article.objects.all().count(), 0
        )
        self.assertEqual(Article.objects.all().count(), 0)

    def test_queryset_methods_on_querysets(self):

        self.assertEqual(
            Article.objects.all().can_read(self.superuser1).count(), 1
        )
        self.assertEqual(
            Article.objects.all().can_write(self.superuser1).count(), 1
        )
        self.assertEqual(Article.objects.all().can_read(self.superuser1).count(), 1)
        self.assertEqual(Article.objects.all().can_write(self.superuser1).count(), 1)
        self.assertEqual(Article.objects.all().active().count(), 1)

    # See: https://code.djangoproject.com/ticket/22817
    def test_queryset_empty_querysets(self):

        self.assertEqual(
            Article.objects.none().can_read(self.superuser1).count(), 0
        )
        self.assertEqual(
            Article.objects.none().can_write(self.superuser1).count(), 0
        )
        self.assertEqual(Article.objects.none().can_read(self.superuser1).count(), 0)
        self.assertEqual(Article.objects.none().can_write(self.superuser1).count(), 0)
        self.assertEqual(Article.objects.none().active().count(), 0)


class AttachmentManagerTests(ArticleTestBase):

    def test_queryset_methods_directly_on_manager(self):

        # Do the same for Attachment which uses ArtickeFkManager
        self.assertEqual(
            Attachment.objects.can_read(self.superuser1).count(), 0
        )
        self.assertEqual(
            Attachment.objects.can_write(self.superuser1).count(), 0
        )
        self.assertEqual(Attachment.objects.can_read(self.superuser1).count(), 0)
        self.assertEqual(Attachment.objects.can_write(self.superuser1).count(), 0)
        self.assertEqual(Attachment.objects.active().count(), 0)

    def test_queryset_methods_on_querysets(self):

        self.assertEqual(
            Attachment.objects.all().can_read(self.superuser1).count(), 0
        )
        self.assertEqual(
            Attachment.objects.all().can_write(self.superuser1).count(), 0
        )
        self.assertEqual(Attachment.objects.all().can_read(self.superuser1).count(), 0)
        self.assertEqual(Attachment.objects.all().can_write(self.superuser1).count(), 0)
        self.assertEqual(Attachment.objects.all().active().count(), 0)

    # See: https://code.djangoproject.com/ticket/22817
    def test_queryset_empty_query_sets(self):

        self.assertEqual(
            Attachment.objects.none().can_read(self.superuser1).count(), 0
        )
        self.assertEqual(Attachment.objects.none().can_read(self.superuser1).count(), 0)
        self.assertEqual(
            Attachment.objects.none().can_write(self.superuser1).count(), 0
        )


@@ 89,7 63,6 @@ class AttachmentManagerTests(ArticleTestBase):


class URLPathManagerTests(ArticleTestBase):

    def test_related_manager_works_with_filters(self):
        root = URLPath.root()
        self.assertNotIn(root.id, [p.id for p in root.children.active()])

M tests/core/test_markdown.py => tests/core/test_markdown.py +53 -62
@@ 11,17 11,17 @@ from ..base import ArticleTestBase

try:
    import pygments

    pygments = True  # NOQA
except ImportError:
    pygments = False


class ArticleMarkdownTests(ArticleTestBase):

    @patch('wiki.core.markdown.settings')
    @patch("wiki.core.markdown.settings")
    def test_do_not_modify_extensions(self, settings):
        extensions = ['footnotes', 'attr_list', 'sane_lists']
        settings.MARKDOWN_KWARGS = {'extensions': extensions}
        extensions = ["footnotes", "attr_list", "sane_lists"]
        settings.MARKDOWN_KWARGS = {"extensions": extensions}
        number_of_extensions = len(extensions)
        ArticleMarkdown(None)
        self.assertEqual(len(extensions), number_of_extensions)


@@ 29,66 29,54 @@ class ArticleMarkdownTests(ArticleTestBase):
    def test_html_removal(self):

        urlpath = URLPath.create_urlpath(
            self.root,
            'html_removal',
            title="Test 1",
            content="</html>only_this"
            self.root, "html_removal", title="Test 1", content="</html>only_this"
        )

        self.assertEqual(urlpath.article.render(), "<p>only_this</p>")


class ResponsiveTableExtensionTests(TestCase):

    def setUp(self):
        super().setUp()
        self.md = markdown.Markdown(extensions=[
            'extra',
            ResponsiveTableExtension()
        ])
        self.md_without = markdown.Markdown(extensions=['extra'])
        self.md = markdown.Markdown(extensions=["extra", ResponsiveTableExtension()])
        self.md_without = markdown.Markdown(extensions=["extra"])

    def test_wrapping(self):
        text = '|th|th|\n|--|--|\n|td|td|'
        expected = '<div class="table-responsive">\n' + self.md_without.convert(text) + '\n</div>'
        text = "|th|th|\n|--|--|\n|td|td|"
        expected = (
            '<div class="table-responsive">\n'
            + self.md_without.convert(text)
            + "\n</div>"
        )
        self.assertEqual(self.md.convert(text), expected)


class CodehiliteTests(TestCase):

    def test_fenced_code(self):
        md = markdown.Markdown(
            extensions=['extra', WikiCodeHiliteExtension()]
        )
        text = (
            "Code:\n"
            "\n"
            "```python\n"
            "echo 'line 1'\n"
            "echo 'line 2'\n"
            "```\n"
        )
        md = markdown.Markdown(extensions=["extra", WikiCodeHiliteExtension()])
        text = "Code:\n" "\n" "```python\n" "echo 'line 1'\n" "echo 'line 2'\n" "```\n"
        result = (
            """<p>Code:</p>\n"""
            """<div class="codehilite-wrap"><div class="codehilite"><pre><span></span><span class="n">echo</span> <span class="s1">&#39;line 1&#39;</span>\n"""
            """<span class="n">echo</span> <span class="s1">&#39;line 2&#39;</span>\n"""
            """</pre></div>\n"""
            """</div>"""
        ) if pygments else (
            """<p>Code:</p>\n"""
            """<div class="codehilite-wrap"><pre class="codehilite"><code class="language-python">echo 'line 1'\n"""
            """echo 'line 2'</code></pre>\n"""
            """</div>"""
            (
                """<p>Code:</p>\n"""
                """<div class="codehilite-wrap"><div class="codehilite"><pre><span></span><span class="n">echo</span> <span class="s1">&#39;line 1&#39;</span>\n"""
                """<span class="n">echo</span> <span class="s1">&#39;line 2&#39;</span>\n"""
                """</pre></div>\n"""
                """</div>"""
            )
            if pygments
            else (
                """<p>Code:</p>\n"""
                """<div class="codehilite-wrap"><pre class="codehilite"><code class="language-python">echo 'line 1'\n"""
                """echo 'line 2'</code></pre>\n"""
                """</div>"""
            )
        )
        self.assertEqual(
            md.convert(text),
            result,
            md.convert(text), result,
        )

    def test_indented_code(self):
        md = markdown.Markdown(
            extensions=['extra', WikiCodeHiliteExtension()]
        )
        md = markdown.Markdown(extensions=["extra", WikiCodeHiliteExtension()])
        text = (
            "Code:\n"
            "\n"


@@ 99,25 87,28 @@ class CodehiliteTests(TestCase):
            "\n"
        )
        result = (
            """<p>Code:</p>\n"""
            """<div class="codehilite-wrap"><table class="codehilitetable"><tr><td class="linenos"><div class="linenodiv"><pre>1\n"""
            """2\n"""
            """3\n"""
            """4</pre></div></td><td class="code"><div class="codehilite"><pre><span></span><span class="ch">#!/usr/bin/python</span>\n"""
            """<span class="k">print</span><span class="p">(</span><span class="s1">&#39;line 1&#39;</span><span class="p">)</span>\n"""
            """<span class="k">print</span><span class="p">(</span><span class="s1">&#39;line 2&#39;</span><span class="p">)</span>\n"""
            """<span class="k">print</span><span class="p">(</span><span class="s1">&#39;æøå&#39;</span><span class="p">)</span>\n"""
            """</pre></div>\n"""
            """</td></tr></table></div>"""
        ) if pygments else (
            """<p>Code:</p>\n"""
            """<div class="codehilite-wrap"><pre class="codehilite"><code class="language-python linenums">#!/usr/bin/python\n"""
            """print('line 1')\n"""
            """print('line 2')\n"""
            """print('æøå')</code></pre>\n"""
            """</div>"""
            (
                """<p>Code:</p>\n"""
                """<div class="codehilite-wrap"><table class="codehilitetable"><tr><td class="linenos"><div class="linenodiv"><pre>1\n"""
                """2\n"""
                """3\n"""
                """4</pre></div></td><td class="code"><div class="codehilite"><pre><span></span><span class="ch">#!/usr/bin/python</span>\n"""
                """<span class="k">print</span><span class="p">(</span><span class="s1">&#39;line 1&#39;</span><span class="p">)</span>\n"""
                """<span class="k">print</span><span class="p">(</span><span class="s1">&#39;line 2&#39;</span><span class="p">)</span>\n"""
                """<span class="k">print</span><span class="p">(</span><span class="s1">&#39;æøå&#39;</span><span class="p">)</span>\n"""
                """</pre></div>\n"""
                """</td></tr></table></div>"""
            )
            if pygments
            else (
                """<p>Code:</p>\n"""
                """<div class="codehilite-wrap"><pre class="codehilite"><code class="language-python linenums">#!/usr/bin/python\n"""
                """print('line 1')\n"""
                """print('line 2')\n"""
                """print('æøå')</code></pre>\n"""
                """</div>"""
            )
        )
        self.assertEqual(
            md.convert(text),
            result,
            md.convert(text), result,
        )

M tests/core/test_models.py => tests/core/test_models.py +17 -26
@@ 13,27 13,28 @@ Group = apps.get_model(settings.GROUP_MODEL)


class WikiCustomUrlPatterns(WikiURLPatterns):

    def get_article_urls(self):
        urlpatterns = [
            re_path('^my-wiki/(?P<article_id>[0-9]+)/$',
            re_path(
                "^my-wiki/(?P<article_id>[0-9]+)/$",
                self.article_view_class.as_view(),
                name='get'
                ),
                name="get",
            ),
        ]
        return urlpatterns

    def get_article_path_urls(self):
        urlpatterns = [
            re_path('^my-wiki/(?P<path>.+/|)$',
            re_path(
                "^my-wiki/(?P<path>.+/|)$",
                self.article_view_class.as_view(),
                name='get'),
                name="get",
            ),
        ]
        return urlpatterns


class ArticleModelTest(TestCase):

    def test_default_fields_of_empty_article(self):

        a = Article.objects.create()


@@ 57,7 58,7 @@ class ArticleModelTest(TestCase):

    def test_str_method_if_have_current_revision(self):

        title = 'Test title'
        title = "Test title"

        a = Article.objects.create()
        ArticleRevision.objects.create(article=a, title=title)


@@ 68,7 69,7 @@ class ArticleModelTest(TestCase):

        a = Article.objects.create()

        expected = 'Article without content (1)'
        expected = "Article without content (1)"

        self.assertEqual(str(a), expected)



@@ 80,16 81,11 @@ class ArticleModelTest(TestCase):

        a2 = Article.objects.create()
        s2 = Site.objects.create(domain="somethingelse.com", name="somethingelse.com")
        URLPath.objects.create(
            article=a2,
            site=s2,
            parent=u1,
            slug='test_slug'
        )
        URLPath.objects.create(article=a2, site=s2, parent=u1, slug="test_slug")

        url = a2.get_absolute_url()

        expected = '/test_slug/'
        expected = "/test_slug/"

        self.assertEqual(url, expected)



@@ 99,13 95,13 @@ class ArticleModelTest(TestCase):

        url = a.get_absolute_url()

        expected = '/1/'
        expected = "/1/"

        self.assertEqual(url, expected)

    def test_article_is_related_to_articlerevision(self):

        title = 'Test title'
        title = "Test title"

        a = Article.objects.create()
        r = ArticleRevision.objects.create(article=a, title=title)


@@ 115,7 111,7 @@ class ArticleModelTest(TestCase):

    def test_article_is_related_to_owner(self):

        u = User.objects.create(username='Noman', password='pass')
        u = User.objects.create(username="Noman", password="pass")
        a = Article.objects.create(owner=u)

        self.assertEqual(a.owner, u)


@@ 131,11 127,6 @@ class ArticleModelTest(TestCase):

    def test_cache(self):
        a = Article.objects.create()
        ArticleRevision.objects.create(
            article=a, title="test", content="# header"
        )
        expected = (
            """<h1 id="wiki-toc-header">header"""
            """.*</h1>"""
        )
        ArticleRevision.objects.create(article=a, title="test", content="# header")
        expected = """<h1 id="wiki-toc-header">header""" """.*</h1>"""
        self.assertRegexpMatches(a.get_cached_content(), expected)

M tests/core/test_sites.py => tests/core/test_sites.py +31 -32
@@ 13,13 13,17 @@ from ..base import wiki_override_settings
class WikiCustomSite(sites.WikiSite):
    def get_article_urls(self):
        urlpatterns = [
            re_path('^some-prefix/(?P<article_id>[0-9]+)/$', self.article_view, name='get'),
            re_path(
                "^some-prefix/(?P<article_id>[0-9]+)/$", self.article_view, name="get"
            ),
        ]
        return urlpatterns

    def get_article_path_urls(self):
        urlpatterns = [
            re_path('^some-other-prefix/(?P<path>.+/|)$', self.article_view, name='get'),
            re_path(
                "^some-other-prefix/(?P<path>.+/|)$", self.article_view, name="get"
            ),
        ]
        return urlpatterns



@@ 29,32 33,32 @@ class WikiCustomConfig(WikiConfig):


urlpatterns = [
    re_path(r'^notify/', include('django_nyt.urls')),
    re_path(r'', include('wiki.urls')),
    re_path(r"^notify/", include("django_nyt.urls")),
    re_path(r"", include("wiki.urls")),
]


@wiki_override_settings(
    INSTALLED_APPS=[
        'tests.testdata',
        'django.contrib.auth.apps.AuthConfig',
        'django.contrib.contenttypes.apps.ContentTypesConfig',
        'django.contrib.sessions.apps.SessionsConfig',
        'django.contrib.admin.apps.AdminConfig',
        'django.contrib.humanize.apps.HumanizeConfig',
        'django.contrib.sites.apps.SitesConfig',
        'django_nyt.apps.DjangoNytConfig',
        'mptt',
        'sekizai',
        'sorl.thumbnail',
        'tests.core.test_sites.WikiCustomConfig',
        'wiki.plugins.attachments.apps.AttachmentsConfig',
        'wiki.plugins.notifications.apps.NotificationsConfig',
        'wiki.plugins.images.apps.ImagesConfig',
        'wiki.plugins.macros.apps.MacrosConfig',
        'wiki.plugins.globalhistory.apps.GlobalHistoryConfig',
        "tests.testdata",
        "django.contrib.auth.apps.AuthConfig",
        "django.contrib.contenttypes.apps.ContentTypesConfig",
        "django.contrib.sessions.apps.SessionsConfig",
        "django.contrib.admin.apps.AdminConfig",
        "django.contrib.humanize.apps.HumanizeConfig",
        "django.contrib.sites.apps.SitesConfig",
        "django_nyt.apps.DjangoNytConfig",
        "mptt",
        "sekizai",
        "sorl.thumbnail",
        "tests.core.test_sites.WikiCustomConfig",
        "wiki.plugins.attachments.apps.AttachmentsConfig",
        "wiki.plugins.notifications.apps.NotificationsConfig",
        "wiki.plugins.images.apps.ImagesConfig",
        "wiki.plugins.macros.apps.MacrosConfig",
        "wiki.plugins.globalhistory.apps.GlobalHistoryConfig",
    ],
    ROOT_URLCONF='tests.core.test_sites',
    ROOT_URLCONF="tests.core.test_sites",
)
class CustomWikiSiteTest(TestCase):
    def setUp(self):


@@ 68,12 72,12 @@ class CustomWikiSiteTest(TestCase):
        reload(urls)

    def test_use_custom_wiki_site(self):
        self.assertEqual(sites.site.__class__.__name__, 'WikiCustomSite')
        self.assertEqual(sites.site.__class__.__name__, "WikiCustomSite")

    def test_get_absolute_url_if_urlpath_set_is_not_exists__no_root_urlconf(self):
        a = Article.objects.create()

        self.assertEqual(a.get_absolute_url(), '/some-prefix/1/')
        self.assertEqual(a.get_absolute_url(), "/some-prefix/1/")

    def test_get_absolute_url_if_urlpath_set_is_exists__no_root_urlconf(self):
        a1 = Article.objects.create()


@@ 82,11 86,6 @@ class CustomWikiSiteTest(TestCase):

        a2 = Article.objects.create()
        s2 = Site.objects.create(domain="somethingelse.com", name="somethingelse.com")
        URLPath.objects.create(
            article=a2,
            site=s2,
            parent=u1,
            slug='test_slug'
        )

        self.assertEqual(a2.get_absolute_url(), '/some-other-prefix/test_slug/')
        URLPath.objects.create(article=a2, site=s2, parent=u1, slug="test_slug")

        self.assertEqual(a2.get_absolute_url(), "/some-other-prefix/test_slug/")

M tests/core/test_template_filters.py => tests/core/test_template_filters.py +101 -93
@@ 1,6 1,13 @@
from django.contrib.auth import get_user_model
from wiki.models import Article, ArticleRevision
from wiki.templatetags.wiki_tags import can_delete, can_moderate, can_read, can_write, get_content_snippet, is_locked
from wiki.templatetags.wiki_tags import (
    can_delete,
    can_moderate,
    can_read,
    can_write,
    get_content_snippet,
    is_locked,
)

from ..base import TemplateTestCase, wiki_override_settings



@@ 15,121 22,121 @@ class GetContentSnippet(TemplateTestCase):
    """

    def test_keyword_at_the_end_of_the_content(self):
        text = 'lorem ' * 80
        content = text + ' list'
        text = "lorem " * 80
        content = text + " list"
        expected = (
            'lorem lorem lorem lorem lorem lorem lorem lorem lorem '
            'lorem lorem lorem lorem lorem lorem <strong>list</strong>'
            "lorem lorem lorem lorem lorem lorem lorem lorem lorem "
            "lorem lorem lorem lorem lorem lorem <strong>list</strong>"
        )

        output = get_content_snippet(content, 'list')
        output = get_content_snippet(content, "list")

        self.assertEqual(output, expected)

    def test_keyword_at_the_beginning_of_the_content(self):
        text = 'lorem ' * 80
        content = 'list ' + text
        text = "lorem " * 80
        content = "list " + text
        expected = (
            '<strong>list</strong> lorem lorem lorem lorem lorem '
            'lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem '
            'lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem '
            'lorem lorem lorem'
            "<strong>list</strong> lorem lorem lorem lorem lorem "
            "lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem "
            "lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem "
            "lorem lorem lorem"
        )

        output = get_content_snippet(content, 'list')
        output = get_content_snippet(content, "list")

        self.assertEqual(output, expected)

    def test_whole_content_consists_of_keywords(self):
        content = 'lorem ' * 80
        expected = '<strong>lorem</strong>' + 30 * ' <strong>lorem</strong>'
        content = "lorem " * 80
        expected = "<strong>lorem</strong>" + 30 * " <strong>lorem</strong>"

        output = get_content_snippet(content, 'lorem')
        output = get_content_snippet(content, "lorem")

        self.assertEqual(output, expected)

    def test_keyword_is_not_in_a_content(self):
        content = 'lorem ' * 80
        content = "lorem " * 80
        expected = (
            'lorem lorem lorem lorem lorem lorem lorem lorem lorem '
            'lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem '
            'lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem'
            "lorem lorem lorem lorem lorem lorem lorem lorem lorem "
            "lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem "
            "lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem lorem"
        )

        output = get_content_snippet(content, 'list')
        output = get_content_snippet(content, "list")

        self.assertEqual(output, expected)

    def test_a_few_keywords_in_content(self):
        text = 'lorem ' * 80
        content = 'list ' + text
        text = "lorem " * 80
        content = "list " + text

        text = 'ipsum ' * 80
        content += text + ' list '
        text = "ipsum " * 80
        content += text + " list "

        text = 'dolorum ' * 80
        content += text + ' list'
        text = "dolorum " * 80
        content += text + " list"

        expected = '<strong>list</strong>' + 30 * ' lorem'
        expected = "<strong>list</strong>" + 30 * " lorem"

        output = get_content_snippet(content, 'list')
        output = get_content_snippet(content, "list")

        self.assertEqual(output, expected)

    # XXX bug or feature?
    def test_keyword_is_in_content_and_max_words_is_zero(self):
        text = 'spam ' * 800
        content = text + ' list'
        text = "spam " * 800
        content = text + " list"

        output = get_content_snippet(content, 'list', 0)
        expected = 'spam ' * 800 + '<strong>list</strong>'
        output = get_content_snippet(content, "list", 0)
        expected = "spam " * 800 + "<strong>list</strong>"

        self.assertEqual(output, expected)

    # XXX bug or feature?
    def test_keyword_is_in_content_and_max_words_is_negative(self):
        text = 'spam ' * 80
        content = text + ' list'
        text = "spam " * 80
        content = text + " list"

        output = get_content_snippet(content, 'list', -10)
        expected = 'spam ' * 75 + '<strong>list</strong>'
        output = get_content_snippet(content, "list", -10)
        expected = "spam " * 75 + "<strong>list</strong>"

        self.assertEqual(output, expected)

    # XXX bug or feature?
    def test_keyword_is_not_in_content_and_max_words_is_zero(self):
        content = 'spam ' * 15
        content = "spam " * 15

        output = get_content_snippet(content, 'list', 0)
        expected = ''
        output = get_content_snippet(content, "list", 0)
        expected = ""

        self.assertEqual(output, expected)

    # XXX bug or feature?
    def test_keyword_is_not_in_content_and_max_words_is_negative(self):
        content = 'spam ' * 15
        content = "spam " * 15

        output = get_content_snippet(content, 'list', -10)
        expected = 'spam spam spam spam spam'
        output = get_content_snippet(content, "list", -10)
        expected = "spam spam spam spam spam"

        self.assertEqual(output, expected)

    def test_no_content(self):
        content = ''
        content = ""

        output = get_content_snippet(content, 'list')
        output = get_content_snippet(content, "list")

        self.assertEqual(output, '')
        self.assertEqual(output, "")

        content = ' '
        content = " "

        output = get_content_snippet(content, 'list')
        output = get_content_snippet(content, "list")

        self.assertEqual(output, '')
        self.assertEqual(output, "")

    def test_strip_tags(self):

        keyword = 'maybe'
        keyword = "maybe"

        content = """
        I should citate Shakespeare or Byron.


@@ 138,9 145,9 @@ class GetContentSnippet(TemplateTestCase):
        """

        expected = (
            'I should citate Shakespeare or Byron. '
            'Or <strong>maybe</strong> copy paste from python '
            'or django documentation. <strong>Maybe.</strong>'
            "I should citate Shakespeare or Byron. "
            "Or <strong>maybe</strong> copy paste from python "
            "or django documentation. <strong>Maybe.</strong>"
        )

        output = get_content_snippet(content, keyword, 30)


@@ 149,13 156,14 @@ class GetContentSnippet(TemplateTestCase):

    def test_max_words_arg(self):

        keyword = 'eggs'
        keyword = "eggs"

        content = """
        knight eggs spam ham eggs guido python eggs circus
        """
        expected = ('knight <strong>eggs</strong> spam ham '
                    '<strong>eggs</strong> guido')
        expected = (
            "knight <strong>eggs</strong> spam ham " "<strong>eggs</strong> guido"
        )

        output = get_content_snippet(content, keyword, 5)



@@ 164,15 172,15 @@ class GetContentSnippet(TemplateTestCase):
        output = get_content_snippet(content, keyword, 0)

        expected = (
            'knight <strong>eggs</strong> spam ham '
            '<strong>eggs</strong> guido python <strong>eggs</strong>'
            "knight <strong>eggs</strong> spam ham "
            "<strong>eggs</strong> guido python <strong>eggs</strong>"
        )
        self.assertEqual(output, expected)

    def test_content_case_preserved(self):
        keyword = 'DOlOr'
        match = 'DoLoR'
        content = 'lorem ipsum %s sit amet' % match
        keyword = "DOlOr"
        match = "DoLoR"
        content = "lorem ipsum %s sit amet" % match
        output = get_content_snippet(content, keyword)
        self.assertIn(match, output)
        self.assertNotIn(keyword, output)


@@ 190,25 198,25 @@ class CanRead(TemplateTestCase):

        a = Article.objects.create()

        u = User.objects.create(username='Nobody', password='pass')
        u = User.objects.create(username="Nobody", password="pass")

        output = can_read(a, u)
        self.assertIs(output, True)

        output = self.render({'article': a, 'user': u})
        self.assertIn('True', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("True", output)

    @wiki_override_settings(WIKI_CAN_READ=lambda *args: False)
    def test_user_dont_have_permission(self):

        a = Article.objects.create()
        u = User.objects.create(username='Noman', password='pass')
        u = User.objects.create(username="Noman", password="pass")

        output = can_read(a, u)
        self.assertIs(output, False)

        output = self.render({'article': a, 'user': u})
        self.assertIn('False', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("False", output)


class CanWrite(TemplateTestCase):


@@ 223,25 231,25 @@ class CanWrite(TemplateTestCase):

        a = Article.objects.create()

        u = User.objects.create(username='Nobody', password='pass')
        u = User.objects.create(username="Nobody", password="pass")

        output = can_write(a, u)
        self.assertIs(output, True)

        output = self.render({'article': a, 'user': u})
        self.assertIn('True', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("True", output)

    @wiki_override_settings(WIKI_CAN_WRITE=lambda *args: False)
    def test_user_dont_have_permission(self):

        a = Article.objects.create()
        u = User.objects.create(username='Noman', password='pass')
        u = User.objects.create(username="Noman", password="pass")

        output = can_write(a, u)
        self.assertIs(output, False)

        output = self.render({'article': a, 'user': u})
        self.assertIn('False', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("False", output)


class CanDelete(TemplateTestCase):


@@ 256,25 264,25 @@ class CanDelete(TemplateTestCase):

        a = Article.objects.create()

        u = User.objects.create(username='Nobody', password='pass')
        u = User.objects.create(username="Nobody", password="pass")

        output = can_delete(a, u)
        self.assertIs(output, True)

        output = self.render({'article': a, 'user': u})
        self.assertIn('True', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("True", output)

    @wiki_override_settings(WIKI_CAN_WRITE=lambda *args: False)
    def test_user_dont_have_permission(self):

        a = Article.objects.create()
        u = User.objects.create(username='Noman', password='pass')
        u = User.objects.create(username="Noman", password="pass")

        output = can_delete(a, u)
        self.assertIs(output, False)

        output = self.render({'article': a, 'user': u})
        self.assertIn('False', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("False", output)


class CanModerate(TemplateTestCase):


@@ 289,24 297,24 @@ class CanModerate(TemplateTestCase):

        a = Article.objects.create()

        u = User.objects.create(username='Nobody', password='pass')
        u = User.objects.create(username="Nobody", password="pass")

        output = can_moderate(a, u)
        self.assertIs(output, True)

        output = self.render({'article': a, 'user': u})
        self.assertIn('True', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("True", output)

    def test_user_dont_have_permission(self):

        a = Article.objects.create()
        u = User.objects.create(username='Noman', password='pass')
        u = User.objects.create(username="Noman", password="pass")

        output = can_moderate(a, u)
        self.assertIs(output, False)

        output = self.render({'article': a, 'user': u})
        self.assertIn('False', output)
        output = self.render({"article": a, "user": u})
        self.assertIn("False", output)


class IsLocked(TemplateTestCase):


@@ 323,8 331,8 @@ class IsLocked(TemplateTestCase):
        output = is_locked(a)
        self.assertIsNone(output)

        output = self.render({'article': a})
        self.assertIn('None', output)
        output = self.render({"article": a})
        self.assertIn("None", output)

    def test_have_current_revision_and_not_locked(self):



@@ 340,8 348,8 @@ class IsLocked(TemplateTestCase):
        output = is_locked(b)
        self.assertIs(output, False)

        output = self.render({'article': a})
        self.assertIn('False', output)
        output = self.render({"article": a})
        self.assertIn("False", output)

    def test_have_current_revision_and_locked(self):



@@ 351,8 359,8 @@ class IsLocked(TemplateTestCase):
        output = is_locked(a)
        self.assertIs(output, True)

        output = self.render({'article': a})
        self.assertIn('True', output)
        output = self.render({"article": a})
        self.assertIn("True", output)


class PluginEnabled(TemplateTestCase):


@@ 364,7 372,7 @@ class PluginEnabled(TemplateTestCase):

    def test_true(self):
        output = self.render({})
        self.assertIn('It is enabled', output)
        self.assertIn("It is enabled", output)


class WikiSettings(TemplateTestCase):


@@ 377,4 385,4 @@ class WikiSettings(TemplateTestCase):
    @wiki_override_settings(WIKI_ACCOUNT_HANDLING=lambda *args: True)
    def test_setting(self):
        output = self.render({})
        self.assertIn('It is enabled', output)
        self.assertIn("It is enabled", output)

M tests/core/test_template_tags.py => tests/core/test_template_tags.py +78 -79
@@ 7,7 7,12 @@ from django.http import HttpRequest
from wiki.conf import settings
from wiki.forms import CreateRootForm
from wiki.models import Article, ArticleForObject, ArticleRevision
from wiki.templatetags.wiki_tags import article_for_object, login_url, wiki_form, wiki_render
from wiki.templatetags.wiki_tags import (
    article_for_object,
    login_url,
    wiki_form,
    wiki_render,
)

from ..base import TemplateTestCase



@@ 27,6 32,7 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
    def setUp(self):
        super().setUp()
        from wiki.templatetags import wiki_tags

        wiki_tags._cache = {}

    def test_obj_arg_is_not_a_django_model(self):


@@ 34,13 40,13 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
        from wiki.templatetags import wiki_tags

        with self.assertRaises(TypeError):
            article_for_object({}, '')
            article_for_object({}, "")

        with self.assertRaises(TypeError):
            article_for_object({'request': 100500}, {})
            article_for_object({"request": 100500}, {})

        with self.assertRaises(TypeError):
            self.render({'obj': 'tiger!'})
            self.render({"obj": "tiger!"})

        self.assertEqual(len(wiki_tags._cache), 0)



@@ 55,7 61,7 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
        self.assertIsNone(cache[obj])
        self.assertEqual(len(cache), 1)

        self.render({'obj': obj})
        self.render({"obj": obj})

        self.assertIn(obj, cache)
        self.assertIsNone(cache[obj])


@@ 67,9 73,7 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
        a = Article.objects.create()
        content_type = ContentType.objects.get_for_model(a)
        ArticleForObject.objects.create(
            article=a,
            content_type=content_type,
            object_id=1
            article=a, content_type=content_type, object_id=1
        )

        output = article_for_object({}, a)


@@ 79,7 83,7 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
        self.assertEqual(cache[a], a)
        self.assertEqual(len(cache), 1)

        self.render({'obj': a})
        self.render({"obj": a})

        self.assertIn(a, cache)
        self.assertEqual(cache[a], a)


@@ 90,7 94,8 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
        model = Article.objects.create()

        from wiki.templatetags import wiki_tags
        wiki_tags._cache = {model: 'spam'}

        wiki_tags._cache = {model: "spam"}

        article_for_object({}, model)



@@ 98,26 103,25 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
        self.assertIsNone(wiki_tags._cache[model])
        self.assertEqual(len(wiki_tags._cache), 1)

        self.render({'obj': model})
        self.render({"obj": model})

        self.assertIn(model, wiki_tags._cache)
        self.assertIsNone(wiki_tags._cache[model])
        self.assertEqual(len(wiki_tags._cache), 1)

        self.assertNotIn('spam', wiki_tags._cache.values())
        self.assertNotIn("spam", wiki_tags._cache.values())

    def test_obj_in__cache_and_articleforobjec_is_exist(self):

        article = Article.objects.create()
        content_type = ContentType.objects.get_for_model(article)
        ArticleForObject.objects.create(
            article=article,
            content_type=content_type,
            object_id=1
            article=article, content_type=content_type, object_id=1
        )

        from wiki.templatetags import wiki_tags
        wiki_tags._cache = {article: 'spam'}

        wiki_tags._cache = {article: "spam"}

        output = article_for_object({}, article)



@@ 125,12 129,12 @@ class ArticleForObjectTemplatetagTest(TemplateTestCase):
        self.assertIn(article, wiki_tags._cache)
        self.assertEqual(wiki_tags._cache[article], article)

        output = self.render({'obj': article})
        output = self.render({"obj": article})

        self.assertIn(article, wiki_tags._cache)
        self.assertEqual(wiki_tags._cache[article], article)

        expected = 'Article without content (1)'
        expected = "Article without content (1)"

        self.assertIn(expected, output)



@@ 145,22 149,18 @@ class WikiRenderTest(TemplateTestCase):

    def tearDown(self):
        from wiki.core.plugins import registry

        registry._cache = {}
        super().tearDown()

    keys = ['article',
            'content',
            'preview',
            'plugins',
            'STATIC_URL',
            'CACHE_TIMEOUT'
            ]
    keys = ["article", "content", "preview", "plugins", "STATIC_URL", "CACHE_TIMEOUT"]

    def test_if_preview_content_is_none(self):

        # monkey patch
        from wiki.core.plugins import registry
        registry._cache = {'ham': 'spam'}

        registry._cache = {"ham": "spam"}

        article = Article.objects.create()



@@ 168,24 168,22 @@ class WikiRenderTest(TemplateTestCase):

        self.assertCountEqual(self.keys, output)

        self.assertEqual(output['article'], article)
        self.assertIsNone(output['content'])
        self.assertIs(output['preview'], False)
        self.assertEqual(output["article"], article)
        self.assertIsNone(output["content"])
        self.assertIs(output["preview"], False)

        self.assertEqual(output['plugins'], {'ham': 'spam'})
        self.assertEqual(output['STATIC_URL'], django_settings.STATIC_URL)
        self.assertEqual(output['CACHE_TIMEOUT'], settings.CACHE_TIMEOUT)
        self.assertEqual(output["plugins"], {"ham": "spam"})
        self.assertEqual(output["STATIC_URL"], django_settings.STATIC_URL)
        self.assertEqual(output["CACHE_TIMEOUT"], settings.CACHE_TIMEOUT)

        # Additional check
        self.render({'article': article, 'pc': None})
        self.render({"article": article, "pc": None})

    def test_called_with_preview_content_and_article_have_current_revision(self):

        article = Article.objects.create()
        ArticleRevision.objects.create(
            article=article,
            title="Test title",
            content="Some beauty test text"
            article=article, title="Test title", content="Some beauty test text"
        )

        content = (


@@ 203,22 201,22 @@ class WikiRenderTest(TemplateTestCase):

        # monkey patch
        from wiki.core.plugins import registry
        registry._cache = {'spam': 'eggs'}

        registry._cache = {"spam": "eggs"}

        output = wiki_render({}, article, preview_content=content)
        self.assertCountEqual(self.keys, output)
        self.assertEqual(output['article'], article)
        self.assertRegexpMatches(output['content'], expected)
        self.assertIs(output['preview'], True)
        self.assertEqual(output['plugins'], {'spam': 'eggs'})
        self.assertEqual(output['STATIC_URL'], django_settings.STATIC_URL)
        self.assertEqual(output['CACHE_TIMEOUT'], settings.CACHE_TIMEOUT)

        output = self.render({'article': article, 'pc': content})
        self.assertEqual(output["article"], article)
        self.assertRegexpMatches(output["content"], expected)
        self.assertIs(output["preview"], True)
        self.assertEqual(output["plugins"], {"spam": "eggs"})
        self.assertEqual(output["STATIC_URL"], django_settings.STATIC_URL)
        self.assertEqual(output["CACHE_TIMEOUT"], settings.CACHE_TIMEOUT)

        output = self.render({"article": article, "pc": content})
        self.assertRegexpMatches(output, expected)

    def test_called_with_preview_content_and_article_dont_have_current_revision(
            self):
    def test_called_with_preview_content_and_article_dont_have_current_revision(self):

        article = Article.objects.create()



@@ 231,22 229,23 @@ class WikiRenderTest(TemplateTestCase):

        # monkey patch
        from wiki.core.plugins import registry
        registry._cache = {'spam': 'eggs'}

        registry._cache = {"spam": "eggs"}

        output = wiki_render({}, article, preview_content=content)

        self.assertCountEqual(self.keys, output)

        self.assertEqual(output['article'], article)
        self.assertEqual(output["article"], article)

        self.assertMultiLineEqual(output['content'], '')
        self.assertIs(output['preview'], True)
        self.assertMultiLineEqual(output["content"], "")
        self.assertIs(output["preview"], True)

        self.assertEqual(output['plugins'], {'spam': 'eggs'})
        self.assertEqual(output['STATIC_URL'], django_settings.STATIC_URL)
        self.assertEqual(output['CACHE_TIMEOUT'], settings.CACHE_TIMEOUT)
        self.assertEqual(output["plugins"], {"spam": "eggs"})
        self.assertEqual(output["STATIC_URL"], django_settings.STATIC_URL)
        self.assertEqual(output["CACHE_TIMEOUT"], settings.CACHE_TIMEOUT)

        self.render({'article': article, 'pc': content})
        self.render({"article": article, "pc": content})


class WikiFormTest(TemplateTestCase):


@@ 258,33 257,33 @@ class WikiFormTest(TemplateTestCase):

    def test_form_obj_is_not_baseform_instance(self):

        context = {'test_key': 'test_value'}
        form_obj = 'ham'
        context = {"test_key": "test_value"}
        form_obj = "ham"

        with self.assertRaises(TypeError):
            wiki_form(context, form_obj)

        self.assertEqual(context, {'test_key': 'test_value'})
        self.assertEqual(context, {"test_key": "test_value"})

        with self.assertRaises(TypeError):
            self.render({'test_key': 100500})
            self.render({"test_key": 100500})

        self.assertEqual(context, {'test_key': 'test_value'})
        self.assertEqual(context, {"test_key": "test_value"})

    def test_form_obj_is_baseform_instance(self):

        context = {'test_key': 'test_value'}
        context = {"test_key": "test_value"}

        # not by any special reasons, just a form
        form_obj = CreateRootForm()

        wiki_form(context, form_obj)

        self.assertEqual(context, {'test_key': 'test_value', 'form': form_obj})
        self.assertEqual(context, {"test_key": "test_value", "form": form_obj})

        self.render({'form_obj': form_obj})
        self.render({"form_obj": form_obj})

        self.assertEqual(context, {'test_key': 'test_value', 'form': form_obj})
        self.assertEqual(context, {"test_key": "test_value", "form": form_obj})


class LoginUrlTest(TemplateTestCase):


@@ 307,51 306,51 @@ class LoginUrlTest(TemplateTestCase):

        r = HttpRequest()
        r.META = {}
        r.path = 'best/test/page/ever/'
        r.path = "best/test/page/ever/"

        output = login_url({'request': r})
        output = login_url({"request": r})

        expected = '/_accounts/login/?next=best/test/page/ever/'
        expected = "/_accounts/login/?next=best/test/page/ever/"

        self.assertEqual(output, expected)

        output = self.render({'request': r})
        output = self.render({"request": r})

        self.assertIn(expected, output)

    def test_login_url_if_query_string_is_empty(self):

        r = HttpRequest()
        r.META = {'QUERY_STRING': ''}
        r.path = 'best/test/page/ever/'
        r.META = {"QUERY_STRING": ""}
        r.path = "best/test/page/ever/"

        output = login_url({'request': r})
        output = login_url({"request": r})

        expected = '/_accounts/login/?next=best/test/page/ever/'
        expected = "/_accounts/login/?next=best/test/page/ever/"

        self.assertEqual(output, expected)

        output = self.render({'request': r})
        output = self.render({"request": r})

        self.assertIn(expected, output)

    def test_login_url_if_query_string_is_not_empty(self):

        r = HttpRequest()
        r.META = {'QUERY_STRING': 'title=Main_page&action=raw'}
        r.path = 'best/test/page/ever/'
        r.META = {"QUERY_STRING": "title=Main_page&action=raw"}
        r.path = "best/test/page/ever/"

        context = {'request': r}
        context = {"request": r}

        output = login_url(context)

        expected = (
            '/_accounts/login/'
            '?next=best/test/page/ever/%3Ftitle%3DMain_page%26action%3Draw'
            "/_accounts/login/"
            "?next=best/test/page/ever/%3Ftitle%3DMain_page%26action%3Draw"
        )

        self.assertEqual(output, expected)

        output = self.render({'request': r})
        output = self.render({"request": r})

        self.assertIn(expected, output)

M tests/core/test_urls.py => tests/core/test_urls.py +17 -19
@@ 8,41 8,44 @@ from ..base import wiki_override_settings


class WikiCustomUrlPatterns(WikiURLPatterns):

    def get_article_urls(self):
        urlpatterns = [
            re_path('^some-prefix/(?P<article_id>[0-9]+)/$',
            re_path(
                "^some-prefix/(?P<article_id>[0-9]+)/$",
                self.article_view_class.as_view(),
                name='get'
                ),
                name="get",
            ),
        ]
        return urlpatterns

    def get_article_path_urls(self):
        urlpatterns = [
            re_path('^some-other-prefix/(?P<path>.+/|)$',
            re_path(
                "^some-other-prefix/(?P<path>.+/|)$",
                self.article_view_class.as_view(),
                name='get'),
                name="get",
            ),
        ]
        return urlpatterns


urlpatterns = [
    re_path(r'^notify/', include('django_nyt.urls')),
    re_path(r'', get_wiki_pattern(url_config_class=WikiCustomUrlPatterns))
    re_path(r"^notify/", include("django_nyt.urls")),
    re_path(r"", get_wiki_pattern(url_config_class=WikiCustomUrlPatterns)),
]


@wiki_override_settings(WIKI_URL_CONFIG_CLASS='tests.core.test_models.WikiCustomUrlPatterns',
                        ROOT_URLCONF='tests.core.test_urls')
@wiki_override_settings(
    WIKI_URL_CONFIG_CLASS="tests.core.test_models.WikiCustomUrlPatterns",
    ROOT_URLCONF="tests.core.test_urls",
)
class ArticleModelReverseMethodTest(TestCase):

    def test_get_absolute_url_if_urlpath_set_is_not_exists__no_root_urlconf(self):
        a = Article.objects.create()

        url = a.get_absolute_url()

        expected = '/some-prefix/1/'
        expected = "/some-prefix/1/"

        self.assertEqual(url, expected)



@@ 54,15 57,10 @@ class ArticleModelReverseMethodTest(TestCase):

        a2 = Article.objects.create()
        s2 = Site.objects.create(domain="somethingelse.com", name="somethingelse.com")
        URLPath.objects.create(
            article=a2,
            site=s2,
            parent=u1,
            slug='test_slug'
        )
        URLPath.objects.create(article=a2, site=s2, parent=u1, slug="test_slug")

        url = a2.get_absolute_url()

        expected = '/some-other-prefix/test_slug/'
        expected = "/some-other-prefix/test_slug/"

        self.assertEqual(url, expected)

M tests/core/test_utils.py => tests/core/test_utils.py +0 -1
@@ 3,7 3,6 @@ from wiki.core.utils import object_to_json_response


class TestUtils(TestCase):

    def test_object_to_json(self):
        """
        Simple test, the actual serialization happens in json.dumps and we

M tests/core/test_views.py => tests/core/test_views.py +350 -390
@@ 12,7 12,14 @@ from wiki import models
from wiki.forms import PermissionsForm, validate_slug_numbers
from wiki.models import ArticleRevision, URLPath, reverse

from ..base import SUPERUSER1_USERNAME, ArticleWebTestUtils, DjangoClientTestBase, RequireRootArticleMixin, SeleniumBase, WebTestBase
from ..base import (
    SUPERUSER1_USERNAME,
    ArticleWebTestUtils,
    DjangoClientTestBase,
    RequireRootArticleMixin,
    SeleniumBase,
    WebTestBase,
)


class RootArticleViewTestsBase(FuncBaseMixin):


@@ 24,17 31,16 @@ class RootArticleViewTestsBase(FuncBaseMixin):
        Test redirecting to /create-root/,
        creating the root article and a simple markup.
        """
        self.get_url('wiki:root')
        self.assertUrlsEqual(resolve_url('wiki:root_create'))
        self.fill({
            '#id_content': 'test heading h1\n====\n',
            '#id_title': 'Wiki Test',
        })
        self.get_url("wiki:root")
        self.assertUrlsEqual(resolve_url("wiki:root_create"))
        self.fill(
            {"#id_content": "test heading h1\n====\n", "#id_title": "Wiki Test",}
        )
        self.submit('button[name="save_changes"]')
        self.assertUrlsEqual('/')
        self.assertTextPresent('test heading h1')
        self.assertUrlsEqual("/")
        self.assertTextPresent("test heading h1")
        article = URLPath.root().article
        self.assertIn('test heading h1', article.current_revision.content)
        self.assertIn("test heading h1", article.current_revision.content)


class RootArticleViewTestsWebTest(RootArticleViewTestsBase, WebTestBase):


@@ 45,37 51,37 @@ class RootArticleViewTestsSelenium(RootArticleViewTestsBase, SeleniumBase):
    pass


class ArticleViewViewTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):
class ArticleViewViewTests(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):

    """
    Tests for article views, assuming a root article already created.
    """

    def dump_db_status(self, message=''):
    def dump_db_status(self, message=""):
        """Debug printing of the complete important database content."""

        print('*** db status *** {}'.format(message))
        print("*** db status *** {}".format(message))

        from wiki.models import Article, ArticleRevision

        for klass in (Article, ArticleRevision, URLPath):
            print('* {} *'.format(klass.__name__))
            print("* {} *".format(klass.__name__))
            pprint.pprint(list(klass.objects.values()), width=240)

    def test_redirects_to_create_if_the_slug_is_unknown(self):

        response = self.get_by_path('unknown/')
        response = self.get_by_path("unknown/")
        self.assertRedirects(
            response,
            resolve_url('wiki:create', path='') + '?slug=unknown'
            response, resolve_url("wiki:create", path="") + "?slug=unknown"
        )

    def test_redirects_to_create_with_lowercased_slug(self):

        response = self.get_by_path('Unknown_Linked_Page/')
        response = self.get_by_path("Unknown_Linked_Page/")
        self.assertRedirects(
            response,
            resolve_url('wiki:create', path='') + '?slug=unknown_linked_page'
            response, resolve_url("wiki:create", path="") + "?slug=unknown_linked_page"
        )

    def test_article_list_update(self):


@@ 84,244 90,213 @@ class ArticleViewViewTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoC
        """

        root_data = {
            'content': '[article_list depth:2]',
            'current_revision': str(URLPath.root().article.current_revision.id),
            'preview': '1',
            'title': 'Root Article'
            "content": "[article_list depth:2]",
            "current_revision": str(URLPath.root().article.current_revision.id),
            "preview": "1",
            "title": "Root Article",
        }

        response = self.client.post(resolve_url('wiki:edit', path=''), root_data)
        self.assertRedirects(response, resolve_url('wiki:root'))
        response = self.client.post(resolve_url("wiki:edit", path=""), root_data)
        self.assertRedirects(response, resolve_url("wiki:root"))

        # verify the new article is added to article_list
        response = self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Sub Article 1', 'slug': 'SubArticle1'}
            resolve_url("wiki:create", path=""),
            {"title": "Sub Article 1", "slug": "SubArticle1"},
        )

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='subarticle1/')
        )
        self.assertContains(self.get_by_path(''), 'Sub Article 1')
        self.assertContains(self.get_by_path(''), 'subarticle1/')
        self.assertRedirects(response, resolve_url("wiki:get", path="subarticle1/"))
        self.assertContains(self.get_by_path(""), "Sub Article 1")
        self.assertContains(self.get_by_path(""), "subarticle1/")

        # verify the deleted article is removed from article_list
        response = self.client.post(
            resolve_url('wiki:delete', path='SubArticle1/'),
            {'confirm': 'on',
             'purge': 'on',
             'revision': str(URLPath.objects.get(slug='subarticle1').article.current_revision.id),
             }
            resolve_url("wiki:delete", path="SubArticle1/"),
            {
                "confirm": "on",
                "purge": "on",
                "revision": str(
                    URLPath.objects.get(slug="subarticle1").article.current_revision.id
                ),
            },
        )

        message = getattr(self.client.cookies['messages'], 'value')
        message = getattr(self.client.cookies["messages"], "value")

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='')
        )
        self.assertRedirects(response, resolve_url("wiki:get", path=""))
        self.assertIn(
            'This article together with all '
            'its contents are now completely gone',
            message)
        self.assertNotContains(self.get_by_path(''), 'Sub Article 1')

            "This article together with all " "its contents are now completely gone",
            message,
        )
        self.assertNotContains(self.get_by_path(""), "Sub Article 1")

class CreateViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class CreateViewTest(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_create_nested_article_in_article(self):

        response = self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Level 1', 'slug': 'Level1', 'content': 'Content level 1'}
        )
        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='level1/')
            resolve_url("wiki:create", path=""),
            {"title": "Level 1", "slug": "Level1", "content": "Content level 1"},
        )
        self.assertRedirects(response, resolve_url("wiki:get", path="level1/"))
        response = self.client.post(
            resolve_url('wiki:create', path='Level1/'),
            {'title': 'test', 'slug': 'Test', 'content': 'Content on level 2'}
        )
        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='level1/test/')
            resolve_url("wiki:create", path="Level1/"),
            {"title": "test", "slug": "Test", "content": "Content on level 2"},
        )
        self.assertRedirects(response, resolve_url("wiki:get", path="level1/test/"))
        response = self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'test',
             'slug': 'Test',
             'content': 'Other content on level 1'
             }
            resolve_url("wiki:create", path=""),
            {"title": "test", "slug": "Test", "content": "Other content on level 1"},
        )

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='test/')
        )
        self.assertContains(
            self.get_by_path('Test/'),
            'Other content on level 1'
        )
        self.assertContains(
            self.get_by_path('Level1/Test/'),
            'Content'
        )  # on level 2')
        self.assertRedirects(response, resolve_url("wiki:get", path="test/"))
        self.assertContains(self.get_by_path("Test/"), "Other content on level 1")
        self.assertContains(self.get_by_path("Level1/Test/"), "Content")  # on level 2')

    def test_illegal_slug(self):

        # A slug cannot be '123' because it gets confused with an article ID.
        response = self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Illegal slug', 'slug': '123', 'content': 'blah'}
        )
        self.assertContains(
            response,
            escape(validate_slug_numbers.message)
            resolve_url("wiki:create", path=""),
            {"title": "Illegal slug", "slug": "123", "content": "blah"},
        )
        self.assertContains(response, escape(validate_slug_numbers.message))


class MoveViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

    def test_illegal_slug(self):
        # A slug cannot be '123' because it gets confused with an article ID.
        response = self.client.post(
            resolve_url('wiki:move', path=''),
            {'destination': '', 'slug': '123', 'redirect': ''}
        )
        self.assertContains(
            response,
            escape(validate_slug_numbers.message)
            resolve_url("wiki:move", path=""),
            {"destination": "", "slug": "123", "redirect": ""},
        )
        self.assertContains(response, escape(validate_slug_numbers.message))

    def test_move(self):
        # Create a hierarchy of pages
        self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test', 'slug': 'test0', 'content': 'Content .0.'}
            resolve_url("wiki:create", path=""),
            {"title": "Test", "slug": "test0", "content": "Content .0."},
        )
        self.client.post(
            resolve_url('wiki:create', path='test0/'),
            {'title': 'Test00', 'slug': 'test00', 'content': 'Content .00.'}
            resolve_url("wiki:create", path="test0/"),
            {"title": "Test00", "slug": "test00", "content": "Content .00."},
        )
        self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test1', 'slug': 'test1', 'content': 'Content .1.'}
            resolve_url("wiki:create", path=""),
            {"title": "Test1", "slug": "test1", "content": "Content .1."},
        )
        self.client.post(
            resolve_url('wiki:create', path='test1/'),
            {'title': 'Tes10', 'slug': 'test10', 'content': 'Content .10.'}
            resolve_url("wiki:create", path="test1/"),
            {"title": "Tes10", "slug": "test10", "content": "Content .10."},
        )
        self.client.post(
            resolve_url('wiki:create', path='test1/test10/'),
            {'title': 'Test100', 'slug': 'test100', 'content': 'Content .100.'}
            resolve_url("wiki:create", path="test1/test10/"),
            {"title": "Test100", "slug": "test100", "content": "Content .100."},
        )

        # Move /test1 => /test0 (an already existing destination slug!)
        response = self.client.post(
            resolve_url('wiki:move', path='test1/'),
            resolve_url("wiki:move", path="test1/"),
            {
                'destination': str(URLPath.root().article.current_revision.id),
                'slug': 'test0',
                'redirect': ''
            }
                "destination": str(URLPath.root().article.current_revision.id),
                "slug": "test0",
                "redirect": "",
            },
        )
        self.assertContains(response, 'A slug named')
        self.assertContains(response, 'already exists.')
        self.assertContains(response, "A slug named")
        self.assertContains(response, "already exists.")

        # Move /test1 >= /test2 (valid slug), no redirect
        test0_id = URLPath.objects.get(slug='test0').article.current_revision.id
        test0_id = URLPath.objects.get(slug="test0").article.current_revision.id
        response = self.client.post(
            resolve_url('wiki:move', path='test1/'),
            {'destination': str(test0_id), 'slug': 'test2', 'redirect': ''}
        )
        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='test0/test2/')
            resolve_url("wiki:move", path="test1/"),
            {"destination": str(test0_id), "slug": "test2", "redirect": ""},
        )
        self.assertRedirects(response, resolve_url("wiki:get", path="test0/test2/"))

        # Check that there is no article displayed in this path anymore
        response = self.get_by_path('test1/')
        self.assertRedirects(response, '/_create/?slug=test1')
        response = self.get_by_path("test1/")
        self.assertRedirects(response, "/_create/?slug=test1")

        # Create /test0/test2/test020
        response = self.client.post(
            resolve_url('wiki:create', path='test0/test2/'),
            {'title': 'Test020', 'slug': 'test020', 'content': 'Content .020.'}
            resolve_url("wiki:create", path="test0/test2/"),
            {"title": "Test020", "slug": "test020", "content": "Content .020."},
        )
        # Move /test0/test2 => /test1new + create redirect
        response = self.client.post(
            resolve_url('wiki:move', path='test0/test2/'),
            resolve_url("wiki:move", path="test0/test2/"),
            {
                'destination': str(URLPath.root().article.current_revision.id),
                'slug': 'test1new', 'redirect': 'true'
            }
        )
        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='test1new/')
                "destination": str(URLPath.root().article.current_revision.id),
                "slug": "test1new",
                "redirect": "true",
            },
        )
        self.assertRedirects(response, resolve_url("wiki:get", path="test1new/"))

        # Check that /test1new is a valid path
        response = self.get_by_path('test1new/')
        self.assertContains(response, 'Content .1.')
        response = self.get_by_path("test1new/")
        self.assertContains(response, "Content .1.")

        # Check that the child article test0/test2/test020 was also moved
        response = self.get_by_path('test1new/test020/')
        self.assertContains(response, 'Content .020.')
        response = self.get_by_path("test1new/test020/")
        self.assertContains(response, "Content .020.")

        response = self.get_by_path('test0/test2/')
        self.assertContains(response, 'Moved: Test1')
        self.assertRegex(response.rendered_content, r'moved to <a[^>]*>wiki:/test1new/')
        response = self.get_by_path("test0/test2/")
        self.assertContains(response, "Moved: Test1")
        self.assertRegex(response.rendered_content, r"moved to <a[^>]*>wiki:/test1new/")

        response = self.get_by_path('test0/test2/test020/')
        self.assertContains(response, 'Moved: Test020')
        self.assertRegex(response.rendered_content, r'moved to <a[^>]*>wiki:/test1new/test020')
        response = self.get_by_path("test0/test2/test020/")
        self.assertContains(response, "Moved: Test020")
        self.assertRegex(
            response.rendered_content, r"moved to <a[^>]*>wiki:/test1new/test020"
        )

        # Check that moved_to was correctly set
        urlsrc = URLPath.get_by_path('/test0/test2/')
        urldst = URLPath.get_by_path('/test1new/')
        urlsrc = URLPath.get_by_path("/test0/test2/")
        urldst = URLPath.get_by_path("/test1new/")
        self.assertEqual(urlsrc.moved_to, urldst)

        # Check that moved_to was correctly set on the child's previous path
        urlsrc = URLPath.get_by_path('/test0/test2/test020/')
        urldst = URLPath.get_by_path('/test1new/test020/')
        urlsrc = URLPath.get_by_path("/test0/test2/test020/")
        urldst = URLPath.get_by_path("/test1new/test020/")
        self.assertEqual(urlsrc.moved_to, urldst)

    def test_translation(self):
        # Test that translation of "Be careful, links to this article" exists.
        self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test', 'slug': 'test0', 'content': 'Content'}
            resolve_url("wiki:create", path=""),
            {"title": "Test", "slug": "test0", "content": "Content"},
        )
        url = resolve_url('wiki:move', path='test0/')
        url = resolve_url("wiki:move", path="test0/")
        response_en = self.client.get(url)
        self.assertIn('Move article', response_en.rendered_content)
        self.assertIn('Be careful', response_en.rendered_content)
        self.assertIn("Move article", response_en.rendered_content)
        self.assertIn("Be careful", response_en.rendered_content)

        with translation.override('da-DK'):
        with translation.override("da-DK"):
            response_da = self.client.get(url)

            self.assertNotIn('Move article', response_da.rendered_content)
            self.assertNotIn('Be careful', response_da.rendered_content)

            self.assertNotIn("Move article", response_da.rendered_content)
            self.assertNotIn("Be careful", response_da.rendered_content)

class DeleteViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class DeleteViewTest(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_render_delete_view(self):
        """
        Other tests do not render the delete view but just sends a POST
        """
        self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test delete', 'slug': 'testdelete', 'content': 'To be deleted'}
        )
        response = self.client.get(
            resolve_url('wiki:delete', path='testdelete/'),
            resolve_url("wiki:create", path=""),
            {"title": "Test delete", "slug": "testdelete", "content": "To be deleted"},
        )
        response = self.client.get(resolve_url("wiki:delete", path="testdelete/"),)
        # test the cache
        self.assertContains(response, 'Delete article')
        self.assertContains(response, "Delete article")

    def test_articles_cache_is_cleared_after_deleting(self):



@@ 329,35 304,31 @@ class DeleteViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientT
        # revealed only by sequence of tests in some particular order

        response = self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test cache', 'slug': 'testcache', 'content': 'Content 1'}
            resolve_url("wiki:create", path=""),
            {"title": "Test cache", "slug": "testcache", "content": "Content 1"},
        )

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='testcache/')
        )
        self.assertRedirects(response, resolve_url("wiki:get", path="testcache/"))

        response = self.client.post(
            resolve_url('wiki:delete', path='testcache/'),
            {'confirm': 'on', 'purge': 'on',
             'revision': str(URLPath.objects.get(slug='testcache').article.current_revision.id)}
            resolve_url("wiki:delete", path="testcache/"),
            {
                "confirm": "on",
                "purge": "on",
                "revision": str(
                    URLPath.objects.get(slug="testcache").article.current_revision.id
                ),
            },
        )

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='')
        )
        self.assertRedirects(response, resolve_url("wiki:get", path=""))
        response = self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test cache', 'slug': 'TestCache', 'content': 'Content 2'}
            resolve_url("wiki:create", path=""),
            {"title": "Test cache", "slug": "TestCache", "content": "Content 2"},
        )

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='testcache/')
        )
        self.assertContains(self.get_by_path('TestCache/'), 'Content 2')
        self.assertRedirects(response, resolve_url("wiki:get", path="testcache/"))
        self.assertContains(self.get_by_path("TestCache/"), "Content 2")

    def test_deleted_view(self):
        """


@@ 366,33 337,41 @@ class DeleteViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientT
        """
        # 1. Create the article
        self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test delete', 'slug': 'testdelete', 'content': 'To be deleted'}
            resolve_url("wiki:create", path=""),
            {"title": "Test delete", "slug": "testdelete", "content": "To be deleted"},
        )
        # 2. Soft delete it
        self.client.post(
            resolve_url('wiki:delete', path='testdelete/'),
            {'confirm': 'on', 'purge': '',
             'revision': str(URLPath.objects.get(slug='testdelete').article.current_revision.id)}
            resolve_url("wiki:delete", path="testdelete/"),
            {
                "confirm": "on",
                "purge": "",
                "revision": str(
                    URLPath.objects.get(slug="testdelete").article.current_revision.id
                ),
            },
        )
        # 3. Get and test that it redirects to the deleted page
        response = self.client.get(
            resolve_url('wiki:get', path='testdelete/'),
            follow=True,
            resolve_url("wiki:get", path="testdelete/"), follow=True,
        )
        # test that it's the Deleted page
        self.assertContains(response, 'Article deleted')
        self.assertContains(response, "Article deleted")

        # 4. Test that we can purge the page now
        self.client.post(
            resolve_url('wiki:deleted', path='testdelete/'),
            {'confirm': 'on', 'purge': 'on',
             'revision': str(URLPath.objects.get(slug='testdelete').article.current_revision.id)}
            resolve_url("wiki:deleted", path="testdelete/"),
            {
                "confirm": "on",
                "purge": "on",
                "revision": str(
                    URLPath.objects.get(slug="testdelete").article.current_revision.id
                ),
            },
        )
        # 5. Test that it's not found anymore
        response = self.client.get(
            resolve_url('wiki:get', path='testdelete/'),
            follow=True,
            resolve_url("wiki:get", path="testdelete/"), follow=True,
        )
        self.assertContains(response, "Add new article")



@@ 408,44 387,39 @@ class DeleteViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientT


class EditViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

    def test_preview_save(self):
        """Test edit preview, edit save and messages."""

        example_data = {
            'content': 'The modified text',
            'current_revision': str(URLPath.root().article.current_revision.id),
            'preview': '1',
            "content": "The modified text",
            "current_revision": str(URLPath.root().article.current_revision.id),
            "preview": "1",
            # 'save': '1',  # probably not too important
            'summary': 'why edited',
            'title': 'wiki test'
            "summary": "why edited",
            "title": "wiki test",
        }

        # test preview
        response = self.client.post(
            resolve_url('wiki:preview', path=''),  # url: '/_preview/'
            example_data
            resolve_url("wiki:preview", path=""), example_data  # url: '/_preview/'
        )

        self.assertContains(response, 'The modified text')
        self.assertContains(response, "The modified text")

    def test_preview_xframe_options_sameorigin(self):
        """Ensure that preview response has X-Frame-Options: SAMEORIGIN"""

        example_data = {
            'content': 'The modified text',
            'current_revision': str(URLPath.root().article.current_revision.id),
            'preview': '1',
            'summary': 'why edited',
            'title': 'wiki test'
            "content": "The modified text",
            "current_revision": str(URLPath.root().article.current_revision.id),
            "preview": "1",
            "summary": "why edited",
            "title": "wiki test",
        }

        response = self.client.post(
            resolve_url('wiki:preview', path=''),
            example_data
        )
        response = self.client.post(resolve_url("wiki:preview", path=""), example_data)

        self.assertEquals(response.get('X-Frame-Options'), 'SAMEORIGIN')
        self.assertEquals(response.get("X-Frame-Options"), "SAMEORIGIN")

    def test_revision_conflict(self):
        """


@@ 453,48 427,40 @@ class EditViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTes
        """

        example_data = {
            'content': 'More modifications',
            'current_revision': str(URLPath.root().article.current_revision.id),
            'preview': '0',
            'save': '1',
            'summary': 'why edited',
            'title': 'wiki test'
            "content": "More modifications",
            "current_revision": str(URLPath.root().article.current_revision.id),
            "preview": "0",
            "save": "1",
            "summary": "why edited",
            "title": "wiki test",
        }

        response = self.client.post(
            resolve_url('wiki:edit', path=''),
            example_data
        )
        response = self.client.post(resolve_url("wiki:edit", path=""), example_data)

        self.assertRedirects(response, resolve_url('wiki:root'))
        self.assertRedirects(response, resolve_url("wiki:root"))

        response = self.client.post(
            resolve_url('wiki:edit', path=''),
            example_data
        )
        response = self.client.post(resolve_url("wiki:edit", path=""), example_data)

        self.assertContains(
            response,
            'While you were editing, someone else changed the revision.'
            response, "While you were editing, someone else changed the revision."
        )


class DiffViewTests(RequireRootArticleMixin, DjangoClientTestBase):

    def setUp(self):
        super().setUp()
        self.root_article.add_revision(ArticleRevision(
            title='New Revision'), save=True
        )
        self.root_article.add_revision(ArticleRevision(title="New Revision"), save=True)
        self.new_revision = self.root_article.current_revision

    def test_diff(self):
        response = self.client.get(reverse('wiki:diff', kwargs={'revision_id': self.root_article.pk}))
        response = self.client.get(
            reverse("wiki:diff", kwargs={"revision_id": self.root_article.pk})
        )
        diff = {
            "diff": ["+ root article content"],
            "other_changes": [["New title", "Root Article"]]
            "other_changes": [["New title", "Root Article"]],
        }
        self.assertJSONEqual(str(response.content, encoding='utf8'), diff)
        self.assertJSONEqual(str(response.content, encoding="utf8"), diff)
        self.assertIsInstance(response, JsonResponse)
        self.assertEqual(response.status_code, 200)



@@ 502,17 468,19 @@ class DiffViewTests(RequireRootArticleMixin, DjangoClientTestBase):
class EditViewTestsBase(RequireRootArticleMixin, FuncBaseMixin):
    def test_edit_save(self):
        old_revision = URLPath.root().article.current_revision
        self.get_url('wiki:edit', path='')
        self.fill({
            '#id_content': 'Something 2',
            '#id_summary': 'why edited',
            '#id_title': 'wiki test'
        })
        self.submit('#id_save')
        self.assertTextPresent('Something 2')
        self.assertTextPresent('successfully added')
        self.get_url("wiki:edit", path="")
        self.fill(
            {
                "#id_content": "Something 2",
                "#id_summary": "why edited",
                "#id_title": "wiki test",
            }
        )
        self.submit("#id_save")
        self.assertTextPresent("Something 2")
        self.assertTextPresent("successfully added")
        new_revision = URLPath.root().article.current_revision
        self.assertIn('Something 2', new_revision.content)
        self.assertIn("Something 2", new_revision.content)
        self.assertEqual(new_revision.revision_number, old_revision.revision_number + 1)




@@ 524,115 492,116 @@ class EditViewTestsSelenium(EditViewTestsBase, SeleniumBase):

    # Javascript only tests:
    def test_preview_and_save(self):
        self.get_url('wiki:edit', path='')
        self.fill({
            '#id_content': 'Some changed stuff',
            '#id_summary': 'why edited',
            '#id_title': 'wiki test'
        })
        self.click('#id_preview')
        self.submit('#id_preview_save_changes')
        self.get_url("wiki:edit", path="")
        self.fill(
            {
                "#id_content": "Some changed stuff",
                "#id_summary": "why edited",
                "#id_title": "wiki test",
            }
        )
        self.click("#id_preview")
        self.submit("#id_preview_save_changes")
        new_revision = URLPath.root().article.current_revision
        self.assertIn("Some changed stuff", new_revision.content)


class SearchViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class SearchViewTest(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_query_string(self):

        response = self.client.get(resolve_url('wiki:search'), {'q': 'Article'})
        self.assertContains(response, 'Root Article')
        response = self.client.get(resolve_url("wiki:search"), {"q": "Article"})
        self.assertContains(response, "Root Article")

    def test_empty_query_string(self):

        response = self.client.get(resolve_url('wiki:search'), {'q': ''})
        self.assertFalse(response.context['articles'])
        response = self.client.get(resolve_url("wiki:search"), {"q": ""})
        self.assertFalse(response.context["articles"])

    def test_hierarchy_search(self):

        c = self.client

        c.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test0', 'slug': 'test0', 'content': 'Content test0'}
            resolve_url("wiki:create", path=""),
            {"title": "Test0", "slug": "test0", "content": "Content test0"},
        )
        c.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test1', 'slug': 'test1', 'content': 'Content test1'}
            resolve_url("wiki:create", path=""),
            {"title": "Test1", "slug": "test1", "content": "Content test1"},
        )
        c.post(
            resolve_url('wiki:create', path='test0/'),
            {'title': 'Subtest0', 'slug': 'subtest0', 'content': 'Content test2'}
            resolve_url("wiki:create", path="test0/"),
            {"title": "Subtest0", "slug": "subtest0", "content": "Content test2"},
        )

        response = c.get(resolve_url('wiki:search', path='test0/'), {'q': 'Content test'})
        articles = response.context['articles']
        response = c.get(
            resolve_url("wiki:search", path="test0/"), {"q": "Content test"}
        )
        articles = response.context["articles"]

        def contains_title(articles, title):
            return any(article.current_revision.title == title for article in articles)

        self.assertIs(contains_title(articles, 'Test0'), True)
        self.assertIs(contains_title(articles, 'Test1'), False)
        self.assertIs(contains_title(articles, 'Subtest0'), True)
        self.assertIs(contains_title(articles, "Test0"), True)
        self.assertIs(contains_title(articles, "Test1"), False)
        self.assertIs(contains_title(articles, "Subtest0"), True)

    def test_hierarchy_search_404(self):

        c = self.client

        response = c.get(resolve_url(
            'wiki:search', path='test0/'), {'q': 'Content test'})
        response = c.get(
            resolve_url("wiki:search", path="test0/"), {"q": "Content test"}
        )

        self.assertEqual(response.status_code, 404)


class DeletedListViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class DeletedListViewTest(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_deleted_articles_list(self):
        response = self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Delete Me', 'slug': 'deleteme', 'content': 'delete me please!'}
            resolve_url("wiki:create", path=""),
            {"title": "Delete Me", "slug": "deleteme", "content": "delete me please!"},
        )

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='deleteme/')
        )
        self.assertRedirects(response, resolve_url("wiki:get", path="deleteme/"))

        response = self.client.post(
            resolve_url('wiki:delete', path='deleteme/'),
            {'confirm': 'on',
             'revision': URLPath.objects.get(slug='deleteme').article.current_revision.id}
            resolve_url("wiki:delete", path="deleteme/"),
            {
                "confirm": "on",
                "revision": URLPath.objects.get(
                    slug="deleteme"
                ).article.current_revision.id,
            },
        )

        self.assertRedirects(
            response,
            resolve_url('wiki:get', path='')
        )
        self.assertRedirects(response, resolve_url("wiki:get", path=""))

        response = self.client.get(resolve_url('wiki:deleted_list'))
        self.assertContains(response, 'Delete Me')
        response = self.client.get(resolve_url("wiki:deleted_list"))
        self.assertContains(response, "Delete Me")


class MergeViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

    def test_merge_preview(self):
        """Test merge preview"""

        first_revision = self.root_article.current_revision
        example_data = {
            'content': 'More modifications\n\nMerge new line',
            'current_revision': str(first_revision.id),
            'preview': '0',
            'save': '1',
            'summary': 'testing merge',
            'title': 'wiki test'
            "content": "More modifications\n\nMerge new line",
            "current_revision": str(first_revision.id),
            "preview": "0",
            "save": "1",
            "summary": "testing merge",
            "title": "wiki test",
        }

        # save a new revision
        self.client.post(
            resolve_url('wiki:edit', path=''),
            example_data
        )
        self.client.post(resolve_url("wiki:edit", path=""), example_data)

        new_revision = models.Article.objects.get(
            id=self.root_article.id


@@ 640,172 609,163 @@ class MergeViewTest(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTe

        response = self.client.get(
            resolve_url(
                'wiki:merge_revision_preview',
                article_id=self.root_article.id, revision_id=first_revision.id
                "wiki:merge_revision_preview",
                article_id=self.root_article.id,
                revision_id=first_revision.id,
            ),
        )

        self.assertContains(response, "Previewing merge between:")
        self.assertContains(
            response,
            'Previewing merge between:'
        )
        self.assertContains(
            response,
            '#{rev_number}'.format(rev_number=first_revision.revision_number)
            response, "#{rev_number}".format(rev_number=first_revision.revision_number)
        )
        self.assertContains(
            response,
            '#{rev_number}'.format(rev_number=new_revision.revision_number)
            response, "#{rev_number}".format(rev_number=new_revision.revision_number)
        )


class SourceViewTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):
class SourceViewTests(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_template_used(self):
        response = self.client.get(reverse('wiki:source', kwargs={
            'article_id': self.root_article.pk,
        }))
        response = self.client.get(
            reverse("wiki:source", kwargs={"article_id": self.root_article.pk,})
        )
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, template_name='wiki/source.html')
        self.assertTemplateUsed(response, template_name="wiki/source.html")

    def test_can_read_permission(self):
        # everybody can see the source of an article
        self.client.logout()
        response = self.client.get(reverse('wiki:source', kwargs={
            'article_id': self.root_article.pk,
        }))
        response = self.client.get(
            reverse("wiki:source", kwargs={"article_id": self.root_article.pk,})
        )
        self.assertEqual(response.status_code, 200)

    def test_content(self):
        response = self.client.get(reverse('wiki:source', kwargs={
            'article_id': self.root_article.pk,
        }))
        self.assertIn('Source of ', str(response.content))
        self.assertEqual(response.context['selected_tab'], 'source')

        response = self.client.get(
            reverse("wiki:source", kwargs={"article_id": self.root_article.pk,})
        )
        self.assertIn("Source of ", str(response.content))
        self.assertEqual(response.context["selected_tab"], "source")

class HistoryViewTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class HistoryViewTests(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_can_read_permission(self):
        response = self.client.get(reverse('wiki:history', kwargs={
            'article_id': self.root_article.pk,
        }))
        response = self.client.get(
            reverse("wiki:history", kwargs={"article_id": self.root_article.pk,})
        )
        self.assertEqual(response.status_code, 200)

    def test_content(self):
        response = self.client.get(reverse('wiki:history', kwargs={
            'article_id': self.root_article.pk,
        }))
        self.assertContains(response, 'History:')
        self.assertEqual(response.context['selected_tab'], 'history')
        response = self.client.get(
            reverse("wiki:history", kwargs={"article_id": self.root_article.pk,})
        )
        self.assertContains(response, "History:")
        self.assertEqual(response.context["selected_tab"], "history")


class DirViewTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

    def test_browse_root(self):
        response = self.client.get(
            reverse('wiki:dir', kwargs={'path': ''}),
        )
        response = self.client.get(reverse("wiki:dir", kwargs={"path": ""}),)
        self.assertRegex(
            response.rendered_content,
            r'Browsing\s+<strong><a href=".+">/</a></strong>'
            response.rendered_content, r'Browsing\s+<strong><a href=".+">/</a></strong>'
        )

    def test_browse_root_query(self):
        self.client.post(
            resolve_url('wiki:create', path=''),
            {'title': 'Test', 'slug': 'test0', 'content': 'Content .0.'}
            resolve_url("wiki:create", path=""),
            {"title": "Test", "slug": "test0", "content": "Content .0."},
        )
        self.client.post(
            resolve_url('wiki:create', path='test0/'),
            {'title': 'Test00', 'slug': 'test00', 'content': 'Content .00.'}
            resolve_url("wiki:create", path="test0/"),
            {"title": "Test00", "slug": "test00", "content": "Content .00."},
        )
        response = self.client.get(
            reverse('wiki:dir', kwargs={'path': ''}),
            {'query': "Test"},
        )
        self.assertRegex(
            response.rendered_content,
            r'1 article'
            reverse("wiki:dir", kwargs={"path": ""}), {"query": "Test"},
        )
        self.assertRegex(response.rendered_content, r"1 article")
        response = self.client.get(
            reverse('wiki:dir', kwargs={'path': 'test0/'}),
            {'query': "Test00"},
        )
        self.assertRegex(
            response.rendered_content,
            r'1 article'
            reverse("wiki:dir", kwargs={"path": "test0/"}), {"query": "Test00"},
        )
        self.assertRegex(response.rendered_content, r"1 article")


class SettingsViewTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class SettingsViewTests(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_change_group(self):
        group = CustomGroup.objects.create()
        response = self.client.post(
            resolve_url('wiki:settings', article_id=self.root_article.pk) + "?f=form0",
            {
                'group': group.pk,
                'owner_username': SUPERUSER1_USERNAME,
            },
            follow=True
            resolve_url("wiki:settings", article_id=self.root_article.pk) + "?f=form0",
            {"group": group.pk, "owner_username": SUPERUSER1_USERNAME,},
            follow=True,
        )
        self.root_article.refresh_from_db()
        self.assertEqual(self.root_article.group, group)
        self.assertEqual(self.root_article.owner, self.superuser1)
        self.assertEqual(len(response.context.get('messages')), 1)
        message = response.context.get('messages')._loaded_messages[0]
        self.assertEqual(len(response.context.get("messages")), 1)
        message = response.context.get("messages")._loaded_messages[0]
        self.assertEqual(message.level, constants.SUCCESS)
        self.assertEqual(message.message, 'Permission settings for the article were updated.')
        self.assertEqual(
            message.message, "Permission settings for the article were updated."
        )

    def test_change_invalid_owner(self):
        self.assertIsNone(self.root_article.owner)
        response = self.client.post(resolve_url('wiki:settings', article_id=self.root_article.pk) + "?f=form0", {
            'owner_username': 'invalid'
        }, follow=True)
        self.assertEqual(response.context['forms'][0].errors['owner_username'], ['No user with that username'])
        response = self.client.post(
            resolve_url("wiki:settings", article_id=self.root_article.pk) + "?f=form0",
            {"owner_username": "invalid"},
            follow=True,
        )
        self.assertEqual(
            response.context["forms"][0].errors["owner_username"],
            ["No user with that username"],
        )

    def test_unchanged_message(self):
        # 1. This is not pretty: Constructs a request object to use to construct
        # the PermissionForm
        get_response = self.client.get(
            resolve_url(
                'wiki:settings',
                article_id=self.root_article.pk
            )
            resolve_url("wiki:settings", article_id=self.root_article.pk)
        )
        # 2. Construct a PermissionForm
        form = PermissionsForm(self.root_article, get_response.wsgi_request)
        # 3. ...in order to get the POST form values that will be transmitted
        form_values = {
            field.html_name: field.value() or "" for field in form
        }
        form_values = {field.html_name: field.value() or "" for field in form}
        # 4. Send an unchanged form
        response = self.client.post(
            resolve_url(
                'wiki:settings',
                article_id=self.root_article.pk
            ) + "?f=form0",
            resolve_url("wiki:settings", article_id=self.root_article.pk) + "?f=form0",
            form_values,
            follow=True
            follow=True,
        )
        self.assertEqual(len(response.context.get('messages')), 1)
        message = response.context.get('messages')._loaded_messages[0]
        self.assertEqual(len(response.context.get("messages")), 1)
        message = response.context.get("messages")._loaded_messages[0]
        self.assertEqual(message.level, constants.SUCCESS)
        self.assertEqual(message.message, 'Your permission settings were unchanged, so nothing saved.')
        self.assertEqual(
            message.message,
            "Your permission settings were unchanged, so nothing saved.",
        )

    @override_settings(ACCOUNT_HANDLING=True)
    def test_login_required(self):
        self.client.logout()
        response = self.client.get(reverse('wiki:settings', kwargs={'article_id': self.root_article.pk}))
        response = self.client.get(
            reverse("wiki:settings", kwargs={"article_id": self.root_article.pk})
        )
        # it's redirecting
        self.assertEqual(response.status_code, 302)

    def test_auth_user(self):
        response = self.client.get(reverse('wiki:settings', kwargs={'article_id': self.root_article.pk}))
        response = self.client.get(
            reverse("wiki:settings", kwargs={"article_id": self.root_article.pk})
        )
        self.assertEqual(response.status_code, 200)

    def test_content(self):
        response = self.client.get(reverse('wiki:settings', kwargs={
            'article_id': self.root_article.pk,
        }))
        self.assertEqual(response.context['selected_tab'], 'settings')
        response = self.client.get(
            reverse("wiki:settings", kwargs={"article_id": self.root_article.pk,})
        )
        self.assertEqual(response.context["selected_tab"], "settings")

M tests/plugins/attachments/test_commands.py => tests/plugins/attachments/test_commands.py +4 -7
@@ 14,18 14,15 @@ class TestAttachmentManagementCommands(TestManagementCommands):
    def setUp(self):
        super().setUp()

        self.test_file = tempfile.NamedTemporaryFile('w', delete=False, suffix=".txt")
        self.test_file = tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt")
        self.test_file.write("test")

        self.child1 = URLPath.create_urlpath(self.root, 'test-slug', title="Test 1")
        self.child1 = URLPath.create_urlpath(self.root, "test-slug", title="Test 1")

        self.attachment1 = models.Attachment.objects.create(
            article=self.child1.article
        )
        self.attachment1 = models.Attachment.objects.create(article=self.child1.article)

        self.attachment1_revision1 = models.AttachmentRevision.objects.create(
            attachment=self.attachment1,
            file=self.test_file.name,
            attachment=self.attachment1, file=self.test_file.name,
        )

    def tearDown(self):

M tests/plugins/attachments/test_models.py => tests/plugins/attachments/test_models.py +5 -7
@@ 3,15 3,13 @@ from wiki.plugins.attachments.models import Attachment, AttachmentRevision


class AttachmentRevisionTests(RequireRootArticleMixin, TestBase):

    def setUp(self):
        super().setUp()
        self.attachment = Attachment.objects.create(
            article=self.root_article, original_filename='blah.txt',
            article=self.root_article, original_filename="blah.txt",
        )
        self.revision = AttachmentRevision.objects.create(
            attachment=self.attachment, file=None, description='muh',
            revision_number=1,
            attachment=self.attachment, file=None, description="muh", revision_number=1,
        )

    def test_revision_no_file(self):


@@ 26,6 24,6 @@ class AttachmentRevisionTests(RequireRootArticleMixin, TestBase):
        self.assertIsNone(self.revision.get_filename())

    def test_str(self):
        self.assertEqual(str(self.revision), "%s: %s (r%d)" % (
            'Root Article', 'blah.txt', 1,
        ))
        self.assertEqual(
            str(self.revision), "%s: %s (r%d)" % ("Root Article", "blah.txt", 1,)
        )

M tests/plugins/attachments/test_views.py => tests/plugins/attachments/test_views.py +43 -50
@@ 7,13 7,14 @@ from wiki.models import URLPath
from ...base import ArticleWebTestUtils, DjangoClientTestBase, RequireRootArticleMixin


class AttachmentTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class AttachmentTests(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def setUp(self):
        super().setUp()
        self.article = self.root_article
        self.test_data = "This is a plain text file"
        self.test_description = 'My file'
        self.test_description = "My file"

    def _createTxtFilestream(self, strData, **kwargs):
        """


@@ 25,29 26,20 @@ class AttachmentTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClient
        Optional Arguments :
        filename : str, Defaults to 'test.txt'
        """
        filename = kwargs.get('filename', 'test.txt')
        data = strData.encode('utf-8')
        filename = kwargs.get("filename", "test.txt")
        data = strData.encode("utf-8")
        filedata = BytesIO(data)
        filestream = InMemoryUploadedFile(
            filedata,
            None,
            filename,
            'text',
            len(data),
            None
            filedata, None, filename, "text", len(data), None
        )
        return filestream

    def _create_test_attachment(self, path):
        url = reverse('wiki:attachments_index', kwargs={'path': path})
        url = reverse("wiki:attachments_index", kwargs={"path": path})
        filestream = self._createTxtFilestream(self.test_data)
        response = self.client.post(
            url,
            {
                'description': self.test_description,
                'file': filestream,
                'save': '1',
            }
            {"description": self.test_description, "file": filestream, "save": "1",},
        )
        self.assertRedirects(response, url)



@@ 57,11 49,13 @@ class AttachmentTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClient
        Uploading a file should preserve the original filename.
        Uploading should not modify file in any way.
        """
        self._create_test_attachment('')
        self._create_test_attachment("")
        # Check the object was created.
        attachment = self.article.shared_plugins_set.all()[0].attachment
        self.assertEqual(attachment.original_filename, 'test.txt')
        self.assertEqual(attachment.current_revision.file.file.read(), self.test_data.encode('utf-8'))
        self.assertEqual(attachment.original_filename, "test.txt")
        self.assertEqual(
            attachment.current_revision.file.file.read(), self.test_data.encode("utf-8")
        )

    def test_replace(self):
        """


@@ 70,10 64,12 @@ class AttachmentTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClient
        "replace" is checked.
        """
        # Upload initial file
        url = reverse('wiki:attachments_index', kwargs={'path': ''})
        url = reverse("wiki:attachments_index", kwargs={"path": ""})
        data = "This is a plain text file"
        filestream = self._createTxtFilestream(data)
        self.client.post(url, {'description': 'My file', 'file': filestream, 'save': '1', })
        self.client.post(
            url, {"description": "My file", "file": filestream, "save": "1",}
        )
        attachment = self.article.shared_plugins_set.all()[0].attachment

        # uploading for the first time should mean that there is only one revision.


@@ 81,45 77,47 @@ class AttachmentTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClient

        # Change url to replacement page.
        url = reverse(
            'wiki:attachments_replace',
            kwargs={'attachment_id': attachment.id, 'article_id': self.article.id}
            "wiki:attachments_replace",
            kwargs={"attachment_id": attachment.id, "article_id": self.article.id},
        )

        # Upload replacement without removing revisions
        replacement_data = data + ' And this is my edit'
        replacement_data = data + " And this is my edit"
        replacement_filestream = self._createTxtFilestream(replacement_data)
        self.client.post(
            url,
            {
                'description': 'Replacement upload',
                'file': replacement_filestream,
            }
            url, {"description": "Replacement upload", "file": replacement_filestream,}
        )
        attachment = self.article.shared_plugins_set.all()[0].attachment
        # Revision count should be two
        self.assertEqual(attachment.attachmentrevision_set.count(), 2)
        # Original filenames should not be modified
        self.assertEqual(attachment.original_filename, 'test.txt')
        self.assertEqual(attachment.original_filename, "test.txt")
        # Latest revision should equal replacment_data
        self.assertEqual(attachment.current_revision.file.file.read(), replacement_data.encode('utf-8'))
        self.assertEqual(
            attachment.current_revision.file.file.read(),
            replacement_data.encode("utf-8"),
        )
        first_replacement = attachment.current_revision

        # Upload another replacement, this time removing most recent revision
        replacement_data2 = data + ' And this is a different edit'
        replacement_data2 = data + " And this is a different edit"
        replacement_filestream2 = self._createTxtFilestream(replacement_data2)
        self.client.post(
            url,
            {
                'description': 'Replacement upload',
                'file': replacement_filestream2,
                'replace': 'on',
            }
                "description": "Replacement upload",
                "file": replacement_filestream2,
                "replace": "on",
            },
        )
        attachment = self.article.shared_plugins_set.all()[0].attachment
        # Revision count should still be two
        self.assertEqual(attachment.attachmentrevision_set.count(), 2)
        # Latest revision should equal replacment_data2
        self.assertEqual(attachment.current_revision.file.file.read(), replacement_data2.encode('utf-8'))
        self.assertEqual(
            attachment.current_revision.file.file.read(),
            replacement_data2.encode("utf-8"),
        )
        # The first replacement should no longer be in the filehistory
        self.assertNotIn(first_replacement, attachment.attachmentrevision_set.all())



@@ 127,23 125,20 @@ class AttachmentTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClient
        """
        Call the search view
        """
        self._create_test_attachment('')
        url = reverse('wiki:attachments_search', kwargs={'path': ''})
        response = self.client.get(url, {'query': self.test_description})
        self._create_test_attachment("")
        url = reverse("wiki:attachments_search", kwargs={"path": ""})
        response = self.client.get(url, {"query": self.test_description})
        self.assertContains(response, self.test_description)

    def get_article(self, cont):
        urlpath = URLPath.create_urlpath(
            URLPath.root(),
            "html_attach",
            title="TestAttach",
            content=cont
            URLPath.root(), "html_attach", title="TestAttach", content=cont
        )
        self._create_test_attachment(urlpath.path)
        return urlpath.article.render()

    def test_render(self):
        output = self.get_article('[attachment:1]')
        output = self.get_article("[attachment:1]")
        expected = (
            '<span class="attachment"><a href=".*attachments/download/1/"'
            ' title="Click to download test\.txt">\s*test\.txt\s*</a>'


@@ 151,10 146,8 @@ class AttachmentTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClient
        self.assertRegexpMatches(output, expected)

    def test_render_missing(self):
        output = self.get_article('[attachment:2]')
        expected = (
            '<span class="attachment attachment-deleted">\s*Attachment with ID #2 is deleted.\s*</span>'
        )
        output = self.get_article("[attachment:2]")
        expected = '<span class="attachment attachment-deleted">\s*Attachment with ID #2 is deleted.\s*</span>'
        self.assertRegexpMatches(output, expected)

    def test_render_title(self):

M tests/plugins/editsection/test_editsection.py => tests/plugins/editsection/test_editsection.py +55 -44
@@ 5,28 5,29 @@ from wiki.models import URLPath
from ...base import DjangoClientTestBase, RequireRootArticleMixin, WebTestBase

TEST_CONTENT = (
    'Title 1\n'
    '=======\n'
    '## Title 2\n'
    'Title 3\n'
    '-------\n'
    'a\n'
    'Paragraph\n'
    '-------\n'
    '### Title 4\n'
    '## Title 5\n'
    '# Title 6\n'
    "Title 1\n"
    "=======\n"
    "## Title 2\n"
    "Title 3\n"
    "-------\n"
    "a\n"
    "Paragraph\n"
    "-------\n"
    "### Title 4\n"
    "## Title 5\n"
    "# Title 6\n"
)


class EditSectionTests(RequireRootArticleMixin, DjangoClientTestBase):
    def test_editsection(self):
        # Test creating links to allow editing all sections individually
        urlpath = URLPath.create_urlpath(URLPath.root(), "testedit",
                                         title="TestEdit", content=TEST_CONTENT)
        urlpath = URLPath.create_urlpath(
            URLPath.root(), "testedit", title="TestEdit", content=TEST_CONTENT
        )
        output = urlpath.article.render()
        expected = (
            r'(?s)'
            r"(?s)"
            r'Title 1<a class="article-edit-title-link" href="/testedit/_plugin/editsection/1-0-0/header/T1/">\[edit\]</a>.*'
            r'Title 2<a class="article-edit-title-link" href="/testedit/_plugin/editsection/1-1-0/header/T2/">\[edit\]</a>.*'
            r'Title 3<a class="article-edit-title-link" href="/testedit/_plugin/editsection/1-2-0/header/T3/">\[edit\]</a>.*'


@@ 37,32 38,37 @@ class EditSectionTests(RequireRootArticleMixin, DjangoClientTestBase):
        self.assertRegex(output, expected)

        # Test wrong header text. Editing should fail with a redirect.
        url = reverse('wiki:editsection',
                      kwargs={'path': 'testedit/', 'location': '1-2-1', 'header': 'Test'})
        url = reverse(
            "wiki:editsection",
            kwargs={"path": "testedit/", "location": "1-2-1", "header": "Test"},
        )
        response = self.client.get(url)
        self.assertRedirects(response, reverse('wiki:get', kwargs={'path': 'testedit/'}))
        self.assertRedirects(
            response, reverse("wiki:get", kwargs={"path": "testedit/"})
        )

        # Test extracting sections for editing
        url = reverse('wiki:editsection',
                      kwargs={'path': 'testedit/', 'location': '1-2-1', 'header': 'T4'})
        response = self.client.get(url)
        expected = (
            '>### Title 4[\r\n]*'
            '<'
        url = reverse(
            "wiki:editsection",
            kwargs={"path": "testedit/", "location": "1-2-1", "header": "T4"},
        )
        response = self.client.get(url)
        expected = ">### Title 4[\r\n]*" "<"
        self.assertRegex(response.rendered_content, expected)

        url = reverse('wiki:editsection',
                      kwargs={'path': 'testedit/', 'location': '1-2-0', 'header': 'T3'})
        url = reverse(
            "wiki:editsection",
            kwargs={"path": "testedit/", "location": "1-2-0", "header": "T3"},
        )
        response = self.client.get(url)
        expected = (
            '>Title 3[\r\n]*'
            '-------[\r\n]*'
            'a[\r\n]*'
            'Paragraph[\r\n]*'
            '-------[\r\n]*'
            '### Title 4[\r\n]*'
            '<'
            ">Title 3[\r\n]*"
            "-------[\r\n]*"
            "a[\r\n]*"
            "Paragraph[\r\n]*"
            "-------[\r\n]*"
            "### Title 4[\r\n]*"
            "<"
        )
        self.assertRegex(response.rendered_content, expected)



@@ 74,26 80,31 @@ class EditSectionEditBase(RequireRootArticleMixin, FuncBaseMixin):
class EditSectionEditTests(EditSectionEditBase, WebTestBase):
    # Test editing a section
    def test_editsection_edit(self):
        urlpath = URLPath.create_urlpath(URLPath.root(), "testedit",
                                         title="TestEdit", content=TEST_CONTENT)
        urlpath = URLPath.create_urlpath(
            URLPath.root(), "testedit", title="TestEdit", content=TEST_CONTENT
        )
        old_number = urlpath.article.current_revision.revision_number

        self.get_literal_url(reverse('wiki:editsection',
                                     kwargs={'path': 'testedit/', 'location': '1-2-0', 'header': 'T3'}))
        self.fill({
            '#id_content': '# Header 1\nContent of the new section'
        })
        self.submit('#id_save')
        self.get_literal_url(
            reverse(
                "wiki:editsection",
                kwargs={"path": "testedit/", "location": "1-2-0", "header": "T3"},
            )
        )
        self.fill({"#id_content": "# Header 1\nContent of the new section"})
        self.submit("#id_save")
        expected = (
            r'(?s)'
            r"(?s)"
            r'Title 1<a class="article-edit-title-link" href="/testedit/_plugin/editsection/1-0-0/header/T1/">\[edit\]</a>.*'
            r'Title 2<a class="article-edit-title-link" href="/testedit/_plugin/editsection/1-1-0/header/T2/">\[edit\]</a>.*'
            r'Header 1<a class="article-edit-title-link" href="/testedit/_plugin/editsection/2-0-0/header/H1/">\[edit\]</a>.*'
            r'Content of the new section.*'
            r"Content of the new section.*"
            r'Title 5<a class="article-edit-title-link" href="/testedit/_plugin/editsection/2-1-0/header/T5/">\[edit\]</a>.*'
            r'Title 6<a class="article-edit-title-link" href="/testedit/_plugin/editsection/3-0-0/header/T6/">\[edit\]</a>.*'
        )
        self.assertRegex(self.last_response.content.decode('utf-8'), expected)
        self.assertRegex(self.last_response.content.decode("utf-8"), expected)

        new_number = URLPath.objects.get(slug='testedit').article.current_revision.revision_number
        new_number = URLPath.objects.get(
            slug="testedit"
        ).article.current_revision.revision_number
        self.assertEqual(new_number, old_number + 1)

M tests/plugins/globalhistory/test_globalhistory.py => tests/plugins/globalhistory/test_globalhistory.py +41 -39
@@ 5,27 5,27 @@ from wiki.models import URLPath
from ...base import ArticleWebTestUtils, DjangoClientTestBase, RequireRootArticleMixin


class GlobalhistoryTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class GlobalhistoryTests(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def test_history(self):
        url = reverse('wiki:globalhistory')
        url0 = reverse('wiki:globalhistory', kwargs={'only_last': '0'})
        url1 = reverse('wiki:globalhistory', kwargs={'only_last': '1'})
        url = reverse("wiki:globalhistory")
        url0 = reverse("wiki:globalhistory", kwargs={"only_last": "0"})
        url1 = reverse("wiki:globalhistory", kwargs={"only_last": "1"})

        response = self.client.get(url)
        expected = (
            '(?s).*Root Article.*no log message.*'
        )
        expected = "(?s).*Root Article.*no log message.*"
        self.assertRegexpMatches(response.rendered_content, expected)

        URLPath.create_urlpath(URLPath.root(), "testhistory1",
                               title="TestHistory1", content="a page",
                               user_message="Comment 1")
        response = self.client.get(url)
        expected = (
            '(?s).*TestHistory1.*Comment 1.*'
            'Root Article.*no log message.*'
        URLPath.create_urlpath(
            URLPath.root(),
            "testhistory1",
            title="TestHistory1",
            content="a page",
            user_message="Comment 1",
        )
        response = self.client.get(url)
        expected = "(?s).*TestHistory1.*Comment 1.*" "Root Article.*no log message.*"
        self.assertRegexpMatches(response.rendered_content, expected)

        urlpath = URLPath.create_urlpath(


@@ 33,12 33,12 @@ class GlobalhistoryTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoCli
            "testhistory2",
            title="TestHistory2",
            content="a page",
            user_message="Comment 2"
            user_message="Comment 2",
        )
        expected = (
            '(?s).*TestHistory2.*Comment 2.*'
            'TestHistory1.*Comment 1.*'
            'Root Article.*no log message.*'
            "(?s).*TestHistory2.*Comment 2.*"
            "TestHistory1.*Comment 1.*"
            "Root Article.*no log message.*"
        )
        response = self.client.get(url)
        self.assertRegexpMatches(response.rendered_content, expected)


@@ 50,20 50,22 @@ class GlobalhistoryTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoCli
        self.assertRegexpMatches(response.rendered_content, expected)

        response = self.client.post(
            reverse('wiki:edit', kwargs={'path': 'testhistory2/'}),
            {'content': 'a page modified',
             'current_revision': str(urlpath.article.current_revision.id),
             'preview': '0',
             'save': '1',
             'summary': 'Testing Revision',
             'title': 'TestHistory2Mod'}
            reverse("wiki:edit", kwargs={"path": "testhistory2/"}),
            {
                "content": "a page modified",
                "current_revision": str(urlpath.article.current_revision.id),
                "preview": "0",
                "save": "1",
                "summary": "Testing Revision",
                "title": "TestHistory2Mod",
            },
        )

        expected = (
            '(?s).*TestHistory2Mod.*Testing Revision.*'
            'TestHistory2.*Comment 2.*'
            'TestHistory1.*Comment 1.*'
            'Root Article.*no log message.*'
            "(?s).*TestHistory2Mod.*Testing Revision.*"
            "TestHistory2.*Comment 2.*"
            "TestHistory1.*Comment 1.*"
            "Root Article.*no log message.*"
        )
        response = self.client.get(url)
        self.assertRegexpMatches(response.rendered_content, expected)


@@ 72,22 74,22 @@ class GlobalhistoryTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoCli
        self.assertRegexpMatches(response.rendered_content, expected)

        expected = (
            '(?s).*TestHistory2Mod.*Testing Revision.*'
            'TestHistory1.*Comment 1.*'
            'Root Article.*no log message.*'
            "(?s).*TestHistory2Mod.*Testing Revision.*"
            "TestHistory1.*Comment 1.*"
            "Root Article.*no log message.*"
        )
        response = self.client.get(url1)
        self.assertRegexpMatches(response.rendered_content, expected)

    def test_translation(self):
        # Test that translation of "List of %s changes in the wiki." exists.
        url = reverse('wiki:globalhistory')
        url = reverse("wiki:globalhistory")
        response_en = self.client.get(url)
        self.assertIn('Global history', response_en.rendered_content)
        self.assertIn('in the wiki', response_en.rendered_content)
        self.assertIn("Global history", response_en.rendered_content)
        self.assertIn("in the wiki", response_en.rendered_content)

        with translation.override('da-DK'):
        with translation.override("da-DK"):
            response_da = self.client.get(url)

            self.assertNotIn('Global history', response_da.rendered_content)
            self.assertNotIn('in the wiki', response_da.rendered_content)
            self.assertNotIn("Global history", response_da.rendered_content)
            self.assertNotIn("in the wiki", response_da.rendered_content)

M tests/plugins/images/test_forms.py => tests/plugins/images/test_forms.py +3 -3
@@ 5,10 5,10 @@ from wiki.plugins.images.forms import PurgeForm

class PurgeFormTest(TestCase):
    def test_not_sure(self):
        form = PurgeForm(data={'confirm': False})
        form = PurgeForm(data={"confirm": False})
        self.assertIs(form.is_valid(), False)
        self.assertEqual(form.errors['confirm'], [gettext('You are not sure enough!')])
        self.assertEqual(form.errors["confirm"], [gettext("You are not sure enough!")])

    def test_sure(self):
        form = PurgeForm(data={'confirm': True})
        form = PurgeForm(data={"confirm": True})
        self.assertIs(form.is_valid(), True)

M tests/plugins/images/test_views.py => tests/plugins/images/test_views.py +81 -61
@@ 10,11 10,15 @@ from wiki.models import URLPath
from wiki.plugins.images import models
from wiki.plugins.images.wiki_plugin import ImagePlugin

from ...base import ArticleWebTestUtils, DjangoClientTestBase, RequireRootArticleMixin, wiki_override_settings
from ...base import (
    ArticleWebTestUtils,
    DjangoClientTestBase,
    RequireRootArticleMixin,
    wiki_override_settings,
)


class ImageTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

    def setUp(self):
        super().setUp()
        self.article = self.root_article


@@ 31,16 35,11 @@ class ImageTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestB
        Optional Arguments :
        filename : str, Defaults to 'test.txt'
        """
        filename = kwargs.get('filename', 'test.gif')
        filename = kwargs.get("filename", "test.gif")
        data = base64.b64decode(str_base64)
        filedata = BytesIO(data)
        filestream = InMemoryUploadedFile(
            filedata,
            None,
            filename,
            'image',
            len(data),
            None
            filedata, None, filename, "image", len(data), None
        )
        return filestream



@@ 52,24 51,24 @@ class ImageTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestB
                plugin_index = cnt
                break
        self.assertGreaterEqual(plugin_index, 0, msg="Image plugin not activated")
        base_edit_url = reverse('wiki:edit', kwargs={'path': path})
        url = base_edit_url + '?f=form{0:d}'.format(plugin_index)
        base_edit_url = reverse("wiki:edit", kwargs={"path": path})
        url = base_edit_url + "?f=form{0:d}".format(plugin_index)
        filestream = self._create_gif_filestream_from_base64(self.test_data)
        response = self.client.post(
            url,
            {
                'unsaved_article_title': self.article.current_revision.title,
                'unsaved_article_content': self.article.current_revision.content,
                'image': filestream,
                'images_save': '1',
                "unsaved_article_title": self.article.current_revision.title,
                "unsaved_article_content": self.article.current_revision.content,
                "image": filestream,
                "images_save": "1",
            },
        )
        self.assertRedirects(response, base_edit_url)

    def test_index(self):
        url = reverse('wiki:images_index', kwargs={'path': ''})
        url = reverse("wiki:images_index", kwargs={"path": ""})
        response = self.client.get(url,)
        self.assertContains(response, 'Images')
        self.assertContains(response, "Images")

    def test_upload(self):
        """


@@ 77,22 76,18 @@ class ImageTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestB
        Uploading a file should preserve the original filename.
        Uploading should not modify file in any way.
        """
        self._create_test_image('')
        self._create_test_image("")
        # Check the object was created.
        image = models.Image.objects.get()
        image_revision = image.current_revision.imagerevision
        self.assertEqual(image_revision.get_filename(), 'test.gif')
        self.assertEqual(image_revision.get_filename(), "test.gif")
        self.assertEqual(
            image_revision.image.file.read(),
            base64.b64decode(self.test_data)
            image_revision.image.file.read(), base64.b64decode(self.test_data)
        )

    def get_article(self, cont, image):
        urlpath = URLPath.create_urlpath(
            URLPath.root(),
            "html_image",
            title="TestImage",
            content=cont
            URLPath.root(), "html_image", title="TestImage", content=cont
        )
        if image:
            self._create_test_image(urlpath.path)


@@ 143,68 138,87 @@ class ImageTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestB
    # https://gist.github.com/guillaumepiot/817a70706587da3bd862835c59ef584e
    def generate_photo_file(self):
        file = BytesIO()
        image = Image.new('RGBA', size=(100, 100), color=(155, 0, 0))
        image.save(file, 'gif')
        file.name = 'test.gif'
        image = Image.new("RGBA", size=(100, 100), color=(155, 0, 0))
        image.save(file, "gif")
        file.name = "test.gif"
        file.seek(0)
        return file

    def test_add_revision(self):
        self._create_test_image(path='')
        self._create_test_image(path="")
        image = models.Image.objects.get()
        before_edit_rev = image.current_revision.revision_number

        response = self.client.post(
            reverse('wiki:images_add_revision', kwargs={
                'article_id': self.root_article, 'image_id': image.pk, 'path': '',
            }),
            data={'image': self.generate_photo_file()}
        )
        self.assertRedirects(
            response, reverse('wiki:edit', kwargs={'path': ''})
        )
            reverse(
                "wiki:images_add_revision",
                kwargs={
                    "article_id": self.root_article,
                    "image_id": image.pk,
                    "path": "",
                },
            ),
            data={"image": self.generate_photo_file()},
        )
        self.assertRedirects(response, reverse("wiki:edit", kwargs={"path": ""}))
        image = models.Image.objects.get()
        self.assertEqual(models.Image.objects.count(), 1)
        self.assertEqual(image.current_revision.previous_revision.revision_number, before_edit_rev)
        self.assertEqual(
            image.current_revision.previous_revision.revision_number, before_edit_rev
        )

    def test_delete_restore_revision(self):
        self._create_test_image(path='')
        self._create_test_image(path="")
        image = models.Image.objects.get()
        before_edit_rev = image.current_revision.revision_number

        response = self.client.get(
            reverse('wiki:images_delete', kwargs={
                'article_id': self.root_article, 'image_id': image.pk, 'path': '',
            }),
            reverse(
                "wiki:images_delete",
                kwargs={
                    "article_id": self.root_article,
                    "image_id": image.pk,
                    "path": "",
                },
            ),
        )
        self.assertRedirects(
            response, reverse('wiki:images_index', kwargs={'path': ''})
            response, reverse("wiki:images_index", kwargs={"path": ""})
        )
        image = models.Image.objects.get()
        self.assertEqual(models.Image.objects.count(), 1)
        self.assertEqual(image.current_revision.previous_revision.revision_number, before_edit_rev)
        self.assertEqual(
            image.current_revision.previous_revision.revision_number, before_edit_rev
        )
        self.assertIs(image.current_revision.deleted, True)

        # RESTORE
        before_edit_rev = image.current_revision.revision_number
        response = self.client.get(
            reverse('wiki:images_restore', kwargs={
                'article_id': self.root_article, 'image_id': image.pk, 'path': '',
            }),
            reverse(
                "wiki:images_restore",
                kwargs={
                    "article_id": self.root_article,
                    "image_id": image.pk,
                    "path": "",
                },
            ),
        )
        self.assertRedirects(
            response, reverse('wiki:images_index', kwargs={'path': ''})
            response, reverse("wiki:images_index", kwargs={"path": ""})
        )
        image = models.Image.objects.get()
        self.assertEqual(models.Image.objects.count(), 1)
        self.assertEqual(image.current_revision.previous_revision.revision_number, before_edit_rev)
        self.assertEqual(
            image.current_revision.previous_revision.revision_number, before_edit_rev
        )
        self.assertFalse(image.current_revision.deleted)

    def test_purge(self):
        """
        Tests that an image is really purged
        """
        self._create_test_image(path='')
        self._create_test_image(path="")
        image = models.Image.objects.get()
        image_revision = image.current_revision.imagerevision
        f_path = image_revision.image.file.name


@@ 212,24 226,30 @@ class ImageTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestB
        self.assertIs(os.path.exists(f_path), True)

        response = self.client.post(
            reverse('wiki:images_purge', kwargs={
                'article_id': self.root_article, 'image_id': image.pk, 'path': '',
            }),
            data={'confirm': True}
            reverse(
                "wiki:images_purge",
                kwargs={
                    "article_id": self.root_article,
                    "image_id": image.pk,
                    "path": "",
                },
            ),
            data={"confirm": True},
        )
        self.assertRedirects(
            response, reverse('wiki:images_index', kwargs={'path': ''})
            response, reverse("wiki:images_index", kwargs={"path": ""})
        )
        self.assertEqual(models.Image.objects.count(), 0)
        self.assertIs(os.path.exists(f_path), False)

    @wiki_override_settings(ACCOUNT_HANDLING=True)
    def test_login_on_revision_add(self):
        self._create_test_image(path='')
        self._create_test_image(path="")
        self.client.logout()
        image = models.Image.objects.get()
        url = reverse('wiki:images_add_revision', kwargs={
            'article_id': self.root_article, 'image_id': image.pk, 'path': '',
        })
        response = self.client.post(url, data={'image': self.generate_photo_file()})
        self.assertRedirects(response, '{}?next={}'.format(reverse('wiki:login'), url))
        url = reverse(
            "wiki:images_add_revision",
            kwargs={"article_id": self.root_article, "image_id": image.pk, "path": "",},
        )
        response = self.client.post(url, data={"image": self.generate_photo_file()})
        self.assertRedirects(response, "{}?next={}".format(reverse("wiki:login"), url))

M tests/plugins/links/test_links.py => tests/plugins/links/test_links.py +15 -16
@@ 8,53 8,52 @@ from wiki.plugins.links.mdx.djangowikilinks import WikiPathExtension
class WikiPathExtensionTests(TestCase):
    def test_works_with_lazy_functions(self):
        URLPath.create_root()
        config = (
            ('base_url', reverse_lazy('wiki:get', kwargs={'path': ''})),
        )
        md = markdown.Markdown(
            extensions=['extra', WikiPathExtension(config)]
        )
        text = '[Français](wiki:/fr)'
        config = (("base_url", reverse_lazy("wiki:get", kwargs={"path": ""})),)
        md = markdown.Markdown(extensions=["extra", WikiPathExtension(config)])
        text = "[Français](wiki:/fr)"
        self.assertEqual(
            md.convert(text),
            '<p><a class="wikipath linknotfound" href="/fr">Français</a></p>',
        )

        URLPath.create_urlpath(URLPath.root(), "linktest",
                               title="LinkTest",
                               content="A page\n#A section\nA line",
                               user_message="Comment1")
        URLPath.create_urlpath(
            URLPath.root(),
            "linktest",
            title="LinkTest",
            content="A page\n#A section\nA line",
            user_message="Comment1",
        )

        # Link to an existing page
        text = '[Test link](wiki:/linktest)'
        text = "[Test link](wiki:/linktest)"
        self.assertEqual(
            md.convert(text),
            '<p><a class="wikipath" href="/linktest/">Test link</a></p>',
        )

        # Link with an empty fragment
        text = '[Test link](wiki:/linktest#)'
        text = "[Test link](wiki:/linktest#)"
        self.assertEqual(
            md.convert(text),
            '<p><a class="wikipath" href="/linktest/#">Test link</a></p>',
        )

        # Link to a header in an existing page
        text = '[Test head](wiki:/linktest#wiki-toc-a-section)'
        text = "[Test head](wiki:/linktest#wiki-toc-a-section)"
        self.assertEqual(
            md.convert(text),
            '<p><a class="wikipath" href="/linktest/#wiki-toc-a-section">Test head</a></p>',
        )

        # Link to a header in a non existing page
        text = '[Test head nonExist](wiki:/linktesterr#wiki-toc-a-section)'
        text = "[Test head nonExist](wiki:/linktesterr#wiki-toc-a-section)"
        self.assertEqual(
            md.convert(text),
            '<p><a class="wikipath linknotfound" href="/linktesterr#wiki-toc-a-section">Test head nonExist</a></p>',
        )

        # Invalid Wiki link: The default markdown link parser takes over
        text = '[Test head err](wiki:/linktest#wiki-toc-a-section#err)'
        text = "[Test head err](wiki:/linktest#wiki-toc-a-section#err)"
        self.assertEqual(
            md.convert(text),
            '<p><a href="wiki:/linktest#wiki-toc-a-section#err">Test head err</a></p>',

M tests/plugins/links/test_urlize.py => tests/plugins/links/test_urlize.py +118 -154
@@ 8,226 8,178 @@ from wiki.plugins.links.mdx.urlize import UrlizeExtension, makeExtension
EXPECTED_LINK_TEMPLATE = (
    '<a href="%s" rel="nofollow" target="_blank">'
    '<span class="fa fa-external-link">'
    '</span>'
    '<span>'
    ' %s'
    '</span>'
    '</a>'
    "</span>"
    "<span>"
    " %s"
    "</span>"
    "</a>"
)

# Template accepts two strings - href value and link text value.
EXPECTED_PARAGRAPH_TEMPLATE = '<p>%s</p>' % EXPECTED_LINK_TEMPLATE
EXPECTED_PARAGRAPH_TEMPLATE = "<p>%s</p>" % EXPECTED_LINK_TEMPLATE


FIXTURE_POSITIVE_MATCHES = [
    # Test surrounding begin/end characters.
    (
        '(example.com)',
        '<p>(' + EXPECTED_LINK_TEMPLATE % ('http://example.com', 'example.com') + ')</p>'
        "(example.com)",
        "<p>("
        + EXPECTED_LINK_TEMPLATE % ("http://example.com", "example.com")
        + ")</p>",
    ),
    (
        '<example.com>',
        '<p>&lt;' + EXPECTED_LINK_TEMPLATE % ('http://example.com', 'example.com') + '&gt;</p>'
        "<example.com>",
        "<p>&lt;"
        + EXPECTED_LINK_TEMPLATE % ("http://example.com", "example.com")
        + "&gt;</p>",
    ),

    # Test protocol specification.
    (
        'http://example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://example.com', 'http://example.com')
        "http://example.com",
        EXPECTED_PARAGRAPH_TEMPLATE % ("http://example.com", "http://example.com"),
    ),
    (
        'https://example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('https://example.com', 'https://example.com')
        "https://example.com",
        EXPECTED_PARAGRAPH_TEMPLATE % ("https://example.com", "https://example.com"),
    ),
    (
        'ftp://example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('ftp://example.com', 'ftp://example.com')
        "ftp://example.com",
        EXPECTED_PARAGRAPH_TEMPLATE % ("ftp://example.com", "ftp://example.com"),
    ),
    (
        'ftps://example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('ftps://example.com', 'ftps://example.com')
        "ftps://example.com",
        EXPECTED_PARAGRAPH_TEMPLATE % ("ftps://example.com", "ftps://example.com"),
    ),
    (
        'example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://example.com', 'example.com')
        "example.com",
        EXPECTED_PARAGRAPH_TEMPLATE % ("http://example.com", "example.com"),
    ),
    (
        'onion://example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('onion://example.com', 'onion://example.com')
        "onion://example.com",
        EXPECTED_PARAGRAPH_TEMPLATE % ("onion://example.com", "onion://example.com"),
    ),
    (
        'onion9+.-://example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('onion9+.-://example.com', 'onion9+.-://example.com')
        "onion9+.-://example.com",
        EXPECTED_PARAGRAPH_TEMPLATE
        % ("onion9+.-://example.com", "onion9+.-://example.com"),
    ),

    # Test various supported host variations.
    ("10.10.1.1", EXPECTED_PARAGRAPH_TEMPLATE % ("http://10.10.1.1", "10.10.1.1")),
    (
        '10.10.1.1',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://10.10.1.1', '10.10.1.1')
    ),
    (
        '1122:3344:5566:7788:9900:aabb:ccdd:eeff',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://1122:3344:5566:7788:9900:aabb:ccdd:eeff', '1122:3344:5566:7788:9900:aabb:ccdd:eeff')
    ),
    (
        '1122:3344:5566:7788:9900:AaBb:cCdD:EeFf',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://1122:3344:5566:7788:9900:AaBb:cCdD:EeFf', '1122:3344:5566:7788:9900:AaBb:cCdD:EeFf')
    ),
    (
        '::1',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://::1', '::1')
    ),
    (
        '1::2:3',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://1::2:3', '1::2:3')
        "1122:3344:5566:7788:9900:aabb:ccdd:eeff",
        EXPECTED_PARAGRAPH_TEMPLATE
        % (
            "http://1122:3344:5566:7788:9900:aabb:ccdd:eeff",
            "1122:3344:5566:7788:9900:aabb:ccdd:eeff",
        ),
    ),
    (
        '1::',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://1::', '1::')
        "1122:3344:5566:7788:9900:AaBb:cCdD:EeFf",
        EXPECTED_PARAGRAPH_TEMPLATE
        % (
            "http://1122:3344:5566:7788:9900:AaBb:cCdD:EeFf",
            "1122:3344:5566:7788:9900:AaBb:cCdD:EeFf",
        ),
    ),
    ("::1", EXPECTED_PARAGRAPH_TEMPLATE % ("http://::1", "::1")),
    ("1::2:3", EXPECTED_PARAGRAPH_TEMPLATE % ("http://1::2:3", "1::2:3")),
    ("1::", EXPECTED_PARAGRAPH_TEMPLATE % ("http://1::", "1::")),
    ("::", EXPECTED_PARAGRAPH_TEMPLATE % ("http://::", "::")),
    (
        '::',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://::', '::')
        "example.com",
        EXPECTED_PARAGRAPH_TEMPLATE % ("http://example.com", "example.com"),
    ),
    (
        'example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://example.com', 'example.com')
        "example.horse",
        EXPECTED_PARAGRAPH_TEMPLATE % ("http://example.horse", "example.horse"),
    ),
    (
        'example.horse',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://example.horse', 'example.horse')
        "my.long.domain.example.com",
        EXPECTED_PARAGRAPH_TEMPLATE
        % ("http://my.long.domain.example.com", "my.long.domain.example.com"),
    ),
    (
        'my.long.domain.example.com',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://my.long.domain.example.com', 'my.long.domain.example.com')
    ),

    # Test port section.

    (
        '10.1.1.1:8000',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://10.1.1.1:8000', '10.1.1.1:8000')
        "10.1.1.1:8000",
        EXPECTED_PARAGRAPH_TEMPLATE % ("http://10.1.1.1:8000", "10.1.1.1:8000"),
    ),

    # Test trailing path specification.
    (
        'http://example.com/',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://example.com/', 'http://example.com/')
        "http://example.com/",
        EXPECTED_PARAGRAPH_TEMPLATE % ("http://example.com/", "http://example.com/"),
    ),
    (
        'http://example.com/my/path',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://example.com/my/path', 'http://example.com/my/path')
        "http://example.com/my/path",
        EXPECTED_PARAGRAPH_TEMPLATE
        % ("http://example.com/my/path", "http://example.com/my/path"),
    ),
    (
        'http://example.com/my/path?param1=value1&param2=value2',
        EXPECTED_PARAGRAPH_TEMPLATE % ('http://example.com/my/path?param1=value1&amp;param2=value2', 'http://example.com/my/path?param1=value1&amp;param2=value2')
        "http://example.com/my/path?param1=value1&param2=value2",
        EXPECTED_PARAGRAPH_TEMPLATE
        % (
            "http://example.com/my/path?param1=value1&amp;param2=value2",
            "http://example.com/my/path?param1=value1&amp;param2=value2",
        ),
    ),

    # Link positioned somewhere within the text, but around whitespace boundary.
    (
        'This is link myhost.example.com',
        "<p>This is link " + EXPECTED_LINK_TEMPLATE % ('http://myhost.example.com', 'myhost.example.com') + "</p>"
        "This is link myhost.example.com",
        "<p>This is link "
        + EXPECTED_LINK_TEMPLATE % ("http://myhost.example.com", "myhost.example.com")
        + "</p>",
    ),
    (
        'myhost.example.com is the link',
        "<p>" + EXPECTED_LINK_TEMPLATE % ('http://myhost.example.com', 'myhost.example.com') + " is the link</p>"
        "myhost.example.com is the link",
        "<p>"
        + EXPECTED_LINK_TEMPLATE % ("http://myhost.example.com", "myhost.example.com")
        + " is the link</p>",
    ),
    (
        'I have best myhost.example.com link ever',
        "<p>I have best " + EXPECTED_LINK_TEMPLATE % ('http://myhost.example.com', 'myhost.example.com') + " link ever</p>"
        "I have best myhost.example.com link ever",
        "<p>I have best "
        + EXPECTED_LINK_TEMPLATE % ("http://myhost.example.com", "myhost.example.com")
        + " link ever</p>",
    ),
    (
        'I have best\nmyhost.example.com link ever',
        "<p>I have best\n" + EXPECTED_LINK_TEMPLATE % ('http://myhost.example.com', 'myhost.example.com') + " link ever</p>"
        "I have best\nmyhost.example.com link ever",
        "<p>I have best\n"
        + EXPECTED_LINK_TEMPLATE % ("http://myhost.example.com", "myhost.example.com")
        + " link ever</p>",
    ),
]


FIXTURE_NEGATIVE_MATCHES = [
    # localhost as part of another word.
    (
        'localhosts',
        '<p>localhosts</p>'
    ),
    (
        'localhost',
        '<p>localhost</p>'

    ),
    (
        'localhost:8000',
        '<p>localhost:8000</p>'
    ),

    ("localhosts", "<p>localhosts</p>"),
    ("localhost", "<p>localhost</p>"),
    ("localhost:8000", "<p>localhost:8000</p>"),
    # Incomplete FQDNs.
    (
        'example.',
        '<p>example.</p>'
    ),
    (
        '.example .com',
        '<p>.example .com</p>'
    ),
    ("example.", "<p>example.</p>"),
    (".example .com", "<p>.example .com</p>"),
    # Invalid FQDNs.
    (
        'example-.com',
        '<p>example-.com</p>'
    ),
    (
        '-example.com',
        '<p>-example.com</p>'
    ),
    (
        'my.-example.com',
        '<p>my.-example.com</p>'
    ),

    ("example-.com", "<p>example-.com</p>"),
    ("-example.com", "<p>-example.com</p>"),
    ("my.-example.com", "<p>my.-example.com</p>"),
    # Invalid IPv6 patterns.
    (
        '1:2:3:4:5:6:7:8:a',  # Use :a, because using a number would match as optional port
        '<p>1:2:3:4:5:6:7:8:a</p>',
    ),
    (
        '1::2::3',
        '<p>1::2::3</p>',
    ),
    (
        '::::1',
        '<p>::::1</p>',
    ),
    (
        '1::::',
        '<p>1::::</p>',
        "1:2:3:4:5:6:7:8:a",  # Use :a, because using a number would match as optional port
        "<p>1:2:3:4:5:6:7:8:a</p>",
    ),

    ("1::2::3", "<p>1::2::3</p>",),
    ("::::1", "<p>::::1</p>",),
    ("1::::", "<p>1::::</p>",),
    # Invalid IPv4 patterns.
    (
        '1.2.3.4.5',
        '<p>1.2.3.4.5</p>',
    ),

    ("1.2.3.4.5", "<p>1.2.3.4.5</p>",),
    # Invalid protocols.
    (
        '9onion://example.com',
        '<p>9onion://example.com</p>',
    ),
    (
        '-onion://example.com',
        '<p>-onion://example.com</p>',
    ),
    (
        '+onion://example.com',
        '<p>+onion://example.com</p>',
    ),
    (
        '.onion://example.com',
        '<p>.onion://example.com</p>',
    ),
    ("9onion://example.com", "<p>9onion://example.com</p>",),
    ("-onion://example.com", "<p>-onion://example.com</p>",),
    ("+onion://example.com", "<p>+onion://example.com</p>",),
    (".onion://example.com", "<p>.onion://example.com</p>",),
]


class TestUrlizeExtension:

    def setup_method(self):
        self.md = markdown.Markdown(extensions=[UrlizeExtension()])



@@ 240,12 192,24 @@ class TestUrlizeExtension:
        assert self.md.convert(markdown_text) == expected_output

    def test_url_with_non_matching_begin_and_end_ignored(self):
        assert self.md.convert('(example.com>') == "<p>%s</p>" % html.escape('(example.com>')
        assert self.md.convert('<example.com)') == "<p>%s</p>" % html.escape('<example.com)')
        assert self.md.convert('(example.com') == "<p>%s</p>" % html.escape('(example.com')
        assert self.md.convert('example.com)') == "<p>%s</p>" % html.escape('example.com)')
        assert self.md.convert('<example.com') == "<p>%s</p>" % html.escape('<example.com')
        assert self.md.convert('example.com>') == "<p>%s</p>" % html.escape('example.com>')
        assert self.md.convert("(example.com>") == "<p>%s</p>" % html.escape(
            "(example.com>"
        )
        assert self.md.convert("<example.com)") == "<p>%s</p>" % html.escape(
            "<example.com)"
        )
        assert self.md.convert("(example.com") == "<p>%s</p>" % html.escape(
            "(example.com"
        )
        assert self.md.convert("example.com)") == "<p>%s</p>" % html.escape(
            "example.com)"
        )
        assert self.md.convert("<example.com") == "<p>%s</p>" % html.escape(
            "<example.com"
        )
        assert self.md.convert("example.com>") == "<p>%s</p>" % html.escape(
            "example.com>"
        )


def test_makeExtension_return_value():

M tests/plugins/macros/test_links.py => tests/plugins/macros/test_links.py +3 -2
@@ 5,7 5,8 @@ from wiki.core import markdown
class WikiLinksTests(RequireRootArticleMixin, TestBase):
    def test_wikilink(self):
        md = markdown.ArticleMarkdown(article=self.root_article)
        md_text = md.convert('[[Root Article]]')
        md_text = md.convert("[[Root Article]]")
        self.assertEqual(
            md_text, '<p><a class="wiki_wikilink wiki-external" href="/Root_Article/">Root Article</a></p>'
            md_text,
            '<p><a class="wiki_wikilink wiki-external" href="/Root_Article/">Root Article</a></p>',
        )

M tests/plugins/macros/test_toc.py => tests/plugins/macros/test_toc.py +8 -10
@@ 7,9 7,7 @@ class TocMacroTests(TestCase):
    def test_toc_renders_table_of_content(self):
        """ Verifies that the [TOC] wiki code renders a Table of Content
        """
        md = markdown.Markdown(
            extensions=['extra', WikiTocExtension()]
        )
        md = markdown.Markdown(extensions=["extra", WikiTocExtension()])
        text = (
            "[TOC]\n"
            "\n"


@@ 23,16 21,16 @@ class TocMacroTests(TestCase):
        )
        expected_output = (
            '<div class="toc">\n'
            '<ul>\n'
            "<ul>\n"
            '<li><a href="#wiki-toc-first-title">First title.</a><ul>\n'
            '<li><a href="#wiki-toc-subsection">Subsection</a></li>\n'
            '</ul>\n'
            '</li>\n'
            '</ul>\n'
            '</div>\n'
            "</ul>\n"
            "</li>\n"
            "</ul>\n"
            "</div>\n"
            '<h1 id="wiki-toc-first-title">First title.</h1>\n'
            '<p>Paragraph 1</p>\n'
            "<p>Paragraph 1</p>\n"
            '<h2 id="wiki-toc-subsection">Subsection</h2>\n'
            '<p>Paragraph 2</p>'
            "<p>Paragraph 2</p>"
        )
        self.assertEqual(md.convert(text), expected_output)

M tests/plugins/notifications/test_forms.py => tests/plugins/notifications/test_forms.py +0 -1
@@ 8,4 8,3 @@ from wiki.plugins.notifications.forms import SettingsFormSet
class SettingsFormTests(RequireSuperuserMixin, TestCase):
    def test_formset(self):
        formset = SettingsFormSet(user=self.superuser1)


M tests/plugins/notifications/test_views.py => tests/plugins/notifications/test_views.py +32 -21
@@ 1,58 1,69 @@
from django.shortcuts import resolve_url
from django_nyt.models import Settings
from tests.base import ArticleWebTestUtils, DjangoClientTestBase, RequireRootArticleMixin
from tests.base import (
    ArticleWebTestUtils,
    DjangoClientTestBase,
    RequireRootArticleMixin,
)


class NotificationSettingsTests(RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase):

class NotificationSettingsTests(
    RequireRootArticleMixin, ArticleWebTestUtils, DjangoClientTestBase
):
    def setUp(self):
        super().setUp()

    def test_login_required(self):
        self.client.logout()
        response = self.client.get(resolve_url('wiki:notification_settings'))
        response = self.client.get(resolve_url("wiki:notification_settings"))
        self.assertEqual(response.status_code, 302)

    def test_when_logged_in(self):
        response = self.client.get(resolve_url('wiki:notification_settings'))
        response = self.client.get(resolve_url("wiki:notification_settings"))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'wiki/plugins/notifications/settings.html')
        self.assertTemplateUsed(response, "wiki/plugins/notifications/settings.html")

    def test_change_settings(self):
        self.settings, __ = Settings.objects.get_or_create(user=self.superuser1, is_default=True)
        self.settings, __ = Settings.objects.get_or_create(
            user=self.superuser1, is_default=True
        )

        url = resolve_url('wiki:notification_settings')
        url = resolve_url("wiki:notification_settings")

        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        data = {'csrf_token': response.context['csrf_token']}
        data = {"csrf_token": response.context["csrf_token"]}

        # management form information, needed because of the formset
        management_form = response.context['form'].management_form
        management_form = response.context["form"].management_form

        for i in 'TOTAL_FORMS', 'INITIAL_FORMS', 'MIN_NUM_FORMS', 'MAX_NUM_FORMS':
            data['%s-%s' % (management_form.prefix, i)] = management_form[i].value()
        for i in "TOTAL_FORMS", "INITIAL_FORMS", "MIN_NUM_FORMS", "MAX_NUM_FORMS":
            data["%s-%s" % (management_form.prefix, i)] = management_form[i].value()

        for i in range(response.context['form'].total_form_count()):
        for i in range(response.context["form"].total_form_count()):
            # get form index 'i'
            current_form = response.context['form'].forms[i]
            current_form = response.context["form"].forms[i]

            # retrieve all the fields
            for field_name in current_form.fields:
                value = current_form[field_name].value()
                data['%s-%s' % (current_form.prefix, field_name)] = value if value is not None else ''
                data["%s-%s" % (current_form.prefix, field_name)] = (
                    value if value is not None else ""
                )

        data['form-TOTAL_FORMS'] = 1
        data['form-0-email'] = 2
        data['form-0-interval'] = 0
        data["form-TOTAL_FORMS"] = 1
        data["form-0-email"] = 2
        data["form-0-interval"] = 0
        # post the request without any change

        response = self.client.post(url, data, follow=True)

        self.assertEqual(len(response.context.get('messages')), 1)
        self.assertEqual(len(response.context.get("messages")), 1)

        message = response.context.get('messages')._loaded_messages[0]
        self.assertIn(message.message, 'You will receive notifications instantly for 0 articles')
        message = response.context.get("messages")._loaded_messages[0]
        self.assertIn(
            message.message, "You will receive notifications instantly for 0 articles"
        )

        # Ensure we didn't create redundant Settings objects
        assert self.superuser1.nyt_settings.all().count() == 1

M tests/settings.py => tests/settings.py +36 -40
@@ 4,57 4,53 @@ from django.urls import reverse_lazy

TESTS_DATA_ROOT = os.path.dirname(__file__)

MEDIA_ROOT = os.path.join(TESTS_DATA_ROOT, 'media')
MEDIA_ROOT = os.path.join(TESTS_DATA_ROOT, "media")

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
    }
}
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3",}}

DEBUG = True
AUTH_USER_MODEL = 'testdata.CustomUser'
WIKI_GROUP_MODEL = 'testdata.CustomGroup'
AUTH_USER_MODEL = "testdata.CustomUser"
WIKI_GROUP_MODEL = "testdata.CustomGroup"
SITE_ID = 1
ROOT_URLCONF = 'tests.testdata.urls'
ROOT_URLCONF = "tests.testdata.urls"
INSTALLED_APPS = [
    'tests.testdata',
    'django.contrib.auth.apps.AuthConfig',
    'django.contrib.contenttypes.apps.ContentTypesConfig',
    'django.contrib.sessions.apps.SessionsConfig',
    'django.contrib.admin.apps.AdminConfig',
    'django.contrib.humanize.apps.HumanizeConfig',
    'django.contrib.sites.apps.SitesConfig',
    'django.contrib.messages',
    'django_nyt.apps.DjangoNytConfig',
    'mptt',
    'sekizai',
    'sorl.thumbnail',
    'wiki.apps.WikiConfig',
    'wiki.plugins.attachments.apps.AttachmentsConfig',
    'wiki.plugins.editsection.apps.EditSectionConfig',
    'wiki.plugins.notifications.apps.NotificationsConfig',
    'wiki.plugins.images.apps.ImagesConfig',
    'wiki.plugins.macros.apps.MacrosConfig',
    'wiki.plugins.globalhistory.apps.GlobalHistoryConfig',
    "tests.testdata",
    "django.contrib.auth.apps.AuthConfig",
    "django.contrib.contenttypes.apps.ContentTypesConfig",
    "django.contrib.sessions.apps.SessionsConfig",
    "django.contrib.admin.apps.AdminConfig",
    "django.contrib.humanize.apps.HumanizeConfig",
    "django.contrib.sites.apps.SitesConfig",
    "django.contrib.messages",
    "django_nyt.apps.DjangoNytConfig",
    "mptt",
    "sekizai",
    "sorl.thumbnail",
    "wiki.apps.WikiConfig",
    "wiki.plugins.attachments.apps.AttachmentsConfig",
    "wiki.plugins.editsection.apps.EditSectionConfig",
    "wiki.plugins.notifications.apps.NotificationsConfig",
    "wiki.plugins.images.apps.ImagesConfig",
    "wiki.plugins.macros.apps.MacrosConfig",
    "wiki.plugins.globalhistory.apps.GlobalHistoryConfig",
    "wiki.plugins.redlinks.apps.RedlinksConfig",
]
MIDDLEWARE = [
    'django.middleware.common.CommonMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    "django.middleware.common.CommonMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
]
USE_TZ = True
SECRET_KEY = 'b^fv_)t39h%9p40)fnkfblo##jkr!$0)lkp6bpy!fi*f$4*92!'
STATIC_URL = '/static/'
SECRET_KEY = "b^fv_)t39h%9p40)fnkfblo##jkr!$0)lkp6bpy!fi*f$4*92!"
STATIC_URL = "/static/"
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.contrib.auth.context_processors.auth",
                "django.template.context_processors.debug",
                "django.template.context_processors.i18n",


@@ 69,4 65,4 @@ TEMPLATES = [
    },
]

LOGIN_REDIRECT_URL = reverse_lazy('wiki:get', kwargs={'path': ''})
LOGIN_REDIRECT_URL = reverse_lazy("wiki:get", kwargs={"path": ""})

M tests/testdata/migrations/0001_initial.py => tests/testdata/migrations/0001_initial.py +137 -32
@@ 11,53 11,158 @@ class Migration(migrations.Migration):
    initial = True

    dependencies = [
        ('auth', '0008_alter_user_username_max_length'),
        ("auth", "0008_alter_user_username_max_length"),
    ]

    operations = [
        migrations.CreateModel(
            name='CustomGroup',
            name="CustomGroup",
            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",
                    ),
                ),
            ],
        ),
        migrations.CreateModel(
            name='VeryCustomUser',
            name="VeryCustomUser",
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('identifier', models.IntegerField()),
                (
                    "id",
                    models.AutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                ("password", models.CharField(max_length=128, verbose_name="password")),
                (
                    "last_login",
                    models.DateTimeField(
                        blank=True, null=True, verbose_name="last login"
                    ),
                ),
                ("identifier", models.IntegerField()),
            ],
            options={
                'abstract': False,
            },
            options={"abstract": False,},
        ),
        migrations.CreateModel(
            name='CustomUser',
            name="CustomUser",
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
                ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
                ('some_field', models.IntegerField(default=0)),
                ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
                (
                    "id",
                    models.AutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                ("password", models.CharField(max_length=128, verbose_name="password")),
                (
                    "last_login",
                    models.DateTimeField(
                        blank=True, null=True, verbose_name="last login"
                    ),
                ),
                (
                    "is_superuser",
                    models.BooleanField(
                        default=False,
                        help_text="Designates that this user has all permissions without explicitly assigning them.",
                        verbose_name="superuser status",
                    ),
                ),
                (
                    "username",
                    models.CharField(
                        error_messages={
                            "unique": "A user with that username already exists."
                        },
                        help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
                        max_length=150,
                        unique=True,
                        validators=[
                            django.contrib.auth.validators.UnicodeUsernameValidator()
                        ],
                        verbose_name="username",
                    ),
                ),
                (
                    "first_name",
                    models.CharField(
                        blank=True, max_length=30, verbose_name="first name"
                    ),
                ),
                (
                    "last_name",
                    models.CharField(
                        blank=True, max_length=150, verbose_name="last name"
                    ),
                ),
                (
                    "email",
                    models.EmailField(
                        blank=True, max_length=254, verbose_name="email address"
                    ),
                ),
                (
                    "is_staff",
                    models.BooleanField(
                        default=False,
                        help_text="Designates whether the user can log into this admin site.",
                        verbose_name="staff status",
                    ),
                ),
                (
                    "is_active",
                    models.BooleanField(
                        default=True,
                        help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
                        verbose_name="active",
                    ),
                ),
                (
                    "date_joined",
                    models.DateTimeField(
                        default=django.utils.timezone.now, verbose_name="date joined"
                    ),
                ),
                ("some_field", models.IntegerField(default=0)),
                (
                    "groups",
                    models.ManyToManyField(
                        blank=True,
                        help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
                        related_name="user_set",
                        related_query_name="user",
                        to="auth.Group",
                        verbose_name="groups",
                    ),
                ),
                (
                    "user_permissions",
                    models.ManyToManyField(
                        blank=True,
                        help_text="Specific permissions for this user.",
                        related_name="user_set",
                        related_query_name="user",
                        to="auth.Permission",
                        verbose_name="user permissions",
                    ),
                ),
            ],
            options={
                'verbose_name': 'user',
                'verbose_name_plural': 'users',
                'abstract': False,
                "verbose_name": "user",
                "verbose_name_plural": "users",
                "abstract": False,
            },
            managers=[
                ('objects', django.contrib.auth.models.UserManager()),
            ],
            managers=[("objects", django.contrib.auth.models.UserManager()),],
        ),
    ]

M tests/testdata/models.py => tests/testdata/models.py +1 -1
@@ 14,4 14,4 @@ class CustomGroup(models.Model):
# user with invalid renamed identifier, and no email field
class VeryCustomUser(AbstractBaseUser):
    identifier = models.IntegerField()
    USERNAME_FIELD = 'identifier'
    USERNAME_FIELD = "identifier"

M tests/testdata/urls.py => tests/testdata/urls.py +10 -9
@@ 4,21 4,22 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, re_path

urlpatterns = [
    re_path(r'^admin/doc/', include('django.contrib.admindocs.urls')),
    re_path(r'^admin/', admin.site.urls),
    re_path(r"^admin/doc/", include("django.contrib.admindocs.urls")),
    re_path(r"^admin/", admin.site.urls),
]

if settings.DEBUG:
    urlpatterns += staticfiles_urlpatterns()
    urlpatterns += [
        re_path(r'^media/(?P<path>.*)$',
            'django.views.static.serve',
            {'document_root': settings.MEDIA_ROOT,
             }),
        re_path(
            r"^media/(?P<path>.*)$",
            "django.views.static.serve",
            {"document_root": settings.MEDIA_ROOT,},
        ),
    ]

urlpatterns += [
    re_path(r'^django_functest/', include('django_functest.urls')),
    re_path(r'^notify/', include('django_nyt.urls')),
    re_path(r'', include('wiki.urls')),
    re_path(r"^django_functest/", include("django_functest.urls")),
    re_path(r"^notify/", include("django_nyt.urls")),
    re_path(r"", include("wiki.urls")),
]