Merge branch 'master' into feature/streamfield-frontend

Conflicts:
	wagtail/wagtailadmin/edit_handlers.py
This commit is contained in:
Matt Westcott 2015-02-04 16:47:14 +00:00
commit 00f50781d1
76 changed files with 1869 additions and 24911 deletions

View file

@ -1,11 +1,21 @@
language: python
env:
- TOXENV=py27-dj17-postgres
- TOXENV=py27-dj17-sqlite
- TOXENV=py32-dj17-postgres
# - TOXENV=py33-dj17-postgres
- TOXENV=py34-dj17-postgres
- TOXENV=py34-dj17-sqlite
matrix:
include:
- env: TOXENV=py27-dj17-postgres
- env: TOXENV=py27-dj17-sqlite
- env: TOXENV=py33-dj17-postgres
- env: TOXENV=py34-dj17-postgres
- env: TOXENV=py34-dj17-sqlite
- env: TOXENV=py27-dj18-postgres
# - env: TOXENV=py27-dj18-sqlite
# - env: TOXENV=py33-dj18-postgres
- env: TOXENV=py34-dj18-postgres
- env: TOXENV=py34-dj18-sqlite
allow_failures:
- env: TOXENV=py27-dj18-postgres
- env: TOXENV=py34-dj18-postgres
- env: TOXENV=py34-dj18-sqlite
# Services
services:

View file

@ -10,14 +10,30 @@ Changelog
* Added contextual links to admin notification messages
* When copying pages, it is now possible to specify a place to copy to (Timo Rieber)
* FieldPanel now accepts an optional 'widget' parameter to override the field's default form widget (Alejandro Giacometti)
* Dropped Django 1.6 support
* Dropped Python 2.6 and 3.2 support
* Dropped Elasticsearch 0.90.x support
* Search view accepts "page" GET parameter in line with pagination
* Reversing `django.contrib.auth.admin.login` will no longer lead to Wagtails login view (making it easier to have front end views)
* Removed dependency on `LOGIN_URL` and `LOGIN_REDIRECT_URL` settings
* Password reset view names namespaced to wagtailadmin
* Removed the need to add permission check on admin views (now automated)
* Added cache-control headers to all admin views
* Added validation to prevent pages being crated with only whitespace characters in their title fields (Frank Wiles)
* Page model fields without a FieldPanel are no longer displayed in the form
* No longer need to specify the base model on InlinePanel definitions
0.8.5 (xx.xx.20xx)
~~~~~~~~~~~~~~~~~~
* Fix: On adding a new page, the available page types are ordered by the displayed verbose name
* Fix: Active admin submenus were not properly closed when activating another
* Fix: get_sitemap_urls is now called on the specific page class so it can now be overridden (Jerel Unruh)
* Fix: (Firefox and IE) Fixed preview window hanging and not refocusing when "Preview" button is clicked again
* Fix: Storage backends that return raw ContentFile objects are now handled correctly when resizing images (@georgewhewell)
* Fix: Punctuation characters are no longer stripped when performing search queries
* Fix: When adding tags where there were none before, it is now possible to save a single tag with multiple words in it
0.8.4 (04.12.2014)
~~~~~~~~~~~~~~~~~~

View file

@ -39,6 +39,9 @@ Contributors
* linibou
* Timo Rieber
* Jerel Unruh
* georgewhewell
* Frank Wiles
* Sebastian Spiegel
Translators
===========

View file

@ -52,7 +52,7 @@ Available at `wagtail.readthedocs.org <http://wagtail.readthedocs.org/>`_ and al
Compatibility
~~~~~~~~~~~~~
Wagtail supports Django 1.7.0+ on Python 2.7, 3.2, 3.3 and 3.4.
Wagtail supports Django 1.7.0+ on Python 2.7, 3.3 and 3.4.
Wagtail's dependencies are summarised at `requirements.io <https://requires.io/github/torchbox/wagtail/requirements>`_.

View file

@ -53,7 +53,7 @@ You now need to create two templates named form_page.html and form_page_landing.
.. code:: html
{% load pageurl rich_text %}
{% load wagtailcore_tags %}
<html>
<head>
<title>{{ self.title }}</title>

View file

@ -154,6 +154,20 @@ One use for this is indexing ``get_*_display`` methods Django creates automatica
index.FilterField('is_private'),
)
Callables also provide a way to index fields from related models. In the example from :ref:`inline_panels`, to index each BookPage by the titles of its related_links:
.. code-block:: python
class BookPage(Page):
# ...
def get_related_link_titles(self):
# Get list of titles and concatenate them
return '\n'.join(self.related_links.all().values_list('title', flat=True))
search_fields = Page.search_fields + [
# ...
index.SearchField('get_related_link_titles'),
]
.. _wagtailsearch_indexing_models:

View file

@ -60,11 +60,12 @@ You will now be able to run the following command to set up an initial file stru
wagtail start myprojectname
**Without Vagrant:** Run the following steps to complete setup of your project (the ``migrate`` step will prompt you to set up a superuser account)::
**Without Vagrant:** Run the following steps to complete setup of your project (the ``createsuperuser`` step will prompt you to set up a superuser account)::
cd myprojectname
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Your site is now accessible at http://localhost:8000, with the admin backend available at http://localhost:8000/admin/ .
@ -95,8 +96,8 @@ To enable Postgres for your project, uncomment the ``psycopg2`` line from your p
pip install -r requirements.txt
createdb -Upostgres myprojectname
./manage.py syncdb
./manage.py migrate
./manage.py createsuperuser
This assumes that your PostgreSQL instance is configured to allow you to connect as the 'postgres' user - if not, you'll need to adjust the ``createdb`` line and the database settings in settings/base.py accordingly.

View file

@ -0,0 +1,49 @@
Custom branding
===============
In your projects with Wagtail, you may wish to replace elements such as the Wagtail logo within the admin interface with your own branding. This can be done through Django's template inheritance mechanism, along with the `django-overextends <https://github.com/stephenmcd/django-overextends>`_ package.
Install django-overextends with ``pip install django-overextends`` (or add ``django-overextends`` to your project's requirements file), and add ``'overextends'`` to your project's ``INSTALLED_APPS``. You now need to create a ``templates/wagtailadmin/`` folder within one of your apps - this may be an existing one, or a new one created for this purpose, for example, ``dashboard``. This app must be registered in ``INSTALLED_APPS`` before ``wagtail.wagtailadmin``::
INSTALLED_APPS = (
# ...
'overextends',
'dashboard',
'wagtail.wagtailcore',
'wagtail.wagtailadmin',
# ...
)
The template blocks that are available to be overridden are as follows:
branding_logo
-------------
To replace the default logo, create a template file ``dashboard/templates/wagtailadmin/base.html`` that overrides the block ``branding_logo``::
{% overextends "wagtailadmin/base.html" %}
{% block branding_logo %}
<img src="{{ STATIC_URL }}images/custom-logo.svg" alt="Custom Project" width="80" />
{% endblock %}
branding_login
--------------
To replace the login message, create a template file ``dashboard/templates/wagtailadmin/login.html`` that overrides the block ``branding_login``::
{% overextends "wagtailadmin/login.html" %}
{% block branding_login %}Sign in to Frank's Site{% endblock %}
branding_welcome
----------------
To replace the welcome message on the dashboard, create a template file ``dashboard/templates/wagtailadmin/home.html`` that overrides the block ``branding_welcome``::
{% overextends "wagtailadmin/home.html" %}
{% block branding_welcome %}Welcome to Frank's Site{% endblock %}

View file

@ -9,4 +9,6 @@ How to
deploying
performance
multilingual_sites
custom_branding
contributing
third_party_tutorials

View file

@ -150,19 +150,6 @@ Wagtail Apps
Settings Variables (settings.py)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Authentication
--------------
.. code-block:: python
LOGIN_URL = 'wagtailadmin_login'
LOGIN_REDIRECT_URL = 'wagtailadmin_home'
These settings variables set the Django authentication system to redirect to the Wagtail admin login. If you plan to use the Django authentication module to log in non-privileged users, you should set these variables to your own login views. See `Django User Authentication`_.
.. _Django User Authentication: https://docs.djangoproject.com/en/dev/topics/auth/
Site Name
---------
@ -480,10 +467,6 @@ settings.py
('text/x-scss', 'django_libsass.SassCompiler'),
)
# Auth settings
LOGIN_URL = 'wagtailadmin_login'
LOGIN_REDIRECT_URL = 'wagtailadmin_home'
# A sample logging configuration. The only tangible logging
# performed by this configuration is to send an email to
# the site admins on every HTTP 500 error when DEBUG=False.

View file

@ -0,0 +1,28 @@
Third-party tutorials
---------------------
.. warning::
The following list is a collection of tutorials and development notes from third-party developers.
Some of the older links may not apply to the latest Wagtail versions.
* `Working With Wagtail: Menus <http://www.tivix.com/blog/working-with-wagtail-menus/>`_ (22 January 2015)
* `Upgrading Wagtail to use Django 1.7 locally using vagrant <https://jossingram.wordpress.com/2014/12/10/upgrading-wagtail-to-use-django-1-7-locally-using-vagrant/>`_ (10 December 2014)
* `Wagtail redirect page. Can link to page, URL and document <https://gist.github.com/alej0varas/e7e334643ceab6e65744>`_ (24 September 2014)
* `Outputing JSON for a model with properties and db fields in Wagtail/Django <https://jossingram.wordpress.com/2014/09/24/outputing-json-for-a-model-with-properties-and-db-fields-in-wagtaildjango/>`_ (24 September 2014)
* `Bi-lingual website using Wagtail CMS <https://jossingram.wordpress.com/2014/09/17/bi-lingual-website-using-wagtail-cms/>`_ (17 September 2015)
* `Wagtail CMS Lesser known features <https://jossingram.wordpress.com/2014/09/12/wagtail-cms-lesser-known-features/>`_ (12 September 2014)
* `Wagtail notes: stateful on/off hallo.js plugins <http://www.coactivate.org/projects/ejucovy/blog/2014/08/09/wagtail-notes-stateful-onoff-hallojs-plugins/>`_ (9 August 2014)
* `Add some blockquote buttons to Wagtail CMS WYSIWYG Editor <https://jossingram.wordpress.com/2014/07/24/add-some-blockquote-buttons-to-wagtail-cms-wysiwyg-editor/>`_ (24 July 2014)
* `Adding Bread Crumbs to the front end in Wagtail CMS <https://jossingram.wordpress.com/2014/07/01/adding-bread-crumbs-to-the-front-end-in-wagtail-cms/>`_ (1 July 2014)
* `Extending hallo.js using Wagtail hooks <https://gist.github.com/jeffrey-hearn/502d0914fa4a930f08ac>`_ (9 July 2014)
* `Wagtail notes: custom tabs per page type <http://www.coactivate.org/projects/ejucovy/blog/2014/05/10/wagtail-notes-custom-tabs-per-page-type/>`_ (10 May 2014)
* `Wagtail notes: managing redirects as pages <http://www.coactivate.org/projects/ejucovy/blog/2014/05/10/wagtail-notes-managing-redirects-as-pages/>`_ (10 May 2014)
* `Wagtail notes: dynamic templates per page <http://www.coactivate.org/projects/ejucovy/blog/2014/05/10/wagtail-notes-dynamic-templates-per-page/>`_ (10 May 2014)
* `Wagtail notes: type-constrained PageChooserPanel <http://www.coactivate.org/projects/ejucovy/blog/2014/05/09/wagtail-notes-type-constrained-pagechooserpanel/>`_ (9 May 2014)
* `How to Run the Wagtail CMS on Gondor <https://gondor.io/blog/2014/02/14/how-run-wagtail-cms-gondor/>`_ (14 February 2014)
* `The first Wagtail tutorial <http://spapas.github.io/2014/02/13/wagtail-tutorial/>`_ (13 February 2014)
.. tip::
We are working on a collection of Wagtail tutorials and best practices. Please tweet `@WagtailCMS <https://twitter.com/WagtailCMS>`_ or `contact us directly <mailto:hello@wagtail.io>`_ to share your Wagtail HOWTOs, development notes or site launches.

View file

@ -3,7 +3,7 @@ Welcome to Wagtail's documentation
Wagtail is a modern, flexible CMS, built on Django.
It supports Django 1.7.0+ on Python 2.7, 3.2, 3.3 and 3.4.
It supports Django 1.7.0+ on Python 2.7, 3.3 and 3.4.
.. toctree::
:maxdepth: 3

View file

@ -15,4 +15,8 @@ Bug fixes
* On adding a new page, the available page types are ordered by the displayed verbose name
* Active admin submenus were not properly closed when activating another
* ``get_sitemap_urls`` is now called on the specific page class so it can now be overridden
* ``get_sitemap_urls`` is now called on the specific page class so it can now be overridden
* (Firefox and IE) Fixed preview window hanging and not refocusing when "Preview" button is clicked again
* Storage backends that return raw ContentFile objects are now handled correctly when resizing images
* Punctuation characters are no longer stripped when performing search queries
* When adding tags where there were none before, it is now possible to save a single tag with multiple words in it

View file

@ -14,11 +14,23 @@ Minor features
~~~~~~~~~~~~~~
* Javascript includes in the admin backend have been moved to the HTML header, to accommodate form widgets that render inline scripts that depend on libraries such as jQuery
* Improvements to the layout of the admin menu footer.
* Improvements to the layout of the admin menu footer
* Added thousands separator for counters on dashboard
* Added contextual links to admin notification messages
* When copying pages, it is now possible to specify a place to copy to
* ``FieldPanel`` now accepts an optional ``widget`` parameter to override the field's default form widget
* Dropped Django 1.6 support
* Dropped Python 2.6 and 3.2 support
* Dropped Elasticsearch 0.90.x support
* Search view accepts "page" GET parameter in line with pagination
* Removed the dependency on `LOGIN_URL` and `LOGIN_REDIRECT_URL` settings
* Password reset view names namespaced to wagtailadmin
* Removed the need to add permission check on admin views (now automated)
* Reversing `django.contrib.auth.admin.login` will no longer lead to Wagtails login view (making it easier to have front end views)
* Added cache-control headers to all admin views. This allows Varnish/Squid/CDN to run on vanilla settings in front of a Wagtail site
* Added validation to prevent pages being crated with only whitespace characters in their title fields
* Page model fields without a FieldPanel are no longer displayed in the form
* No longer need to specify the base model on InlinePanel definitions
Bug fixes
@ -28,6 +40,24 @@ Bug fixes
Upgrade considerations
======================
Support for older Django/Python/Elasticsearch versions dropped
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This release drops support for Django 1.6, Python 2.6/3.2 and Elasticsearch 0.90.x. Please make sure these are updated before upgrading.
If you are upgrading from Elasticsearch 0.90.x, you may also need to update the ``elasticsearch`` pip package to a version greater than ``1.0`` as well.
Login/Password reset views renamed
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It was previously possible to reverse the Wagtail login using django.contrib.auth.views.login.
This is no longer possible. Update any references to `wagtailadmin_login`.
Password reset view name has changed from `password_reset` to `wagtailadmin_password_reset`.
You no longer need `LOGIN_URL` and `LOGIN_REDIRECT_URL` to point to Wagtail admin.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Javascript includes in admin backend have been moved
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -1,5 +1,5 @@
Django>=1.6.2,<1.8
django-modelcluster>=0.4
django-modelcluster>=0.5
django-taggit==0.12.2
django-treebeard==2.0
django-treebeard==3.0
six>=1.7.0

View file

@ -31,9 +31,9 @@ install_requires = [
"Django>=1.7.0,<1.8",
"django-compressor>=1.4",
"django-libsass>=0.2",
"django-modelcluster>=0.4",
"django-modelcluster>=0.5",
"django-taggit==0.12.2",
"django-treebeard==2.0",
"django-treebeard==3.0",
"Pillow>=2.6.1",
"beautifulsoup4>=4.3.2",
"html5lib==0.999",
@ -71,7 +71,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Framework :: Django',

106
tox.ini
View file

@ -3,9 +3,9 @@ base =
django-compressor>=1.4
django-libsass>=0.2
libsass==0.5.1
django-modelcluster>=0.3
django-modelcluster>=0.5
django-taggit==0.12.1
django-treebeard==2.0
django-treebeard==3.0
Pillow>=2.3.0
beautifulsoup4>=4.3.2
html5lib==0.999
@ -26,6 +26,9 @@ dj17 =
dj171 =
Django==1.7.1
dj18 =
https://github.com/django/django/archive/stable/1.8.x.zip#egg=django
py2 =
unicodecsv>=0.9.4
@ -39,12 +42,17 @@ usedevelop = True
envlist =
py27-dj17-postgres,
py27-dj17-sqlite,
py32-dj17-postgres,
py32-dj17-sqlite,
py33-dj17-postgres,
py33-dj17-sqlite,
py34-dj17-postgres,
py34-dj17-sqlite
py34-dj17-sqlite,
py27-dj18-postgres,
py27-dj18-sqlite,
py33-dj18-postgres,
py33-dj18-sqlite,
py34-dj18-postgres,
py34-dj18-sqlite
# mysql not currently supported
# (wagtail.wagtailimages.tests.TestImageEditView currently fails with a
@ -87,25 +95,6 @@ setenv =
DATABASE_ENGINE=django.db.backends.mysql
DATABASE_USER=wagtail
[testenv:py32-dj17-postgres]
basepython=python3.2
deps =
{[deps]base}
{[deps]py3}
{[deps]dj17}
psycopg2==2.5.3
setenv =
DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
[testenv:py32-dj17-sqlite]
basepython=python3.2
deps =
{[deps]base}
{[deps]py3}
{[deps]dj171}
setenv =
DATABASE_ENGINE=django.db.backends.sqlite3
[testenv:py33-dj17-postgres]
basepython=python3.3
deps =
@ -143,3 +132,72 @@ deps =
{[deps]dj171}
setenv =
DATABASE_ENGINE=django.db.backends.sqlite3
[testenv:py27-dj18-postgres]
basepython=python2.7
deps =
{[deps]base}
{[deps]py2}
{[deps]dj18}
psycopg2==2.5.3
setenv =
DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
[testenv:py27-dj18-sqlite]
basepython=python2.7
deps =
{[deps]base}
{[deps]py2}
{[deps]dj18}
setenv =
DATABASE_ENGINE=django.db.backends.sqlite3
[testenv:py27-dj18-mysql]
basepython=python2.7
deps =
{[deps]base}
{[deps]py2}
{[deps]dj18}
MySQL-python==1.2.5
setenv =
DATABASE_ENGINE=django.db.backends.mysql
DATABASE_USER=wagtail
[testenv:py33-dj18-postgres]
basepython=python3.3
deps =
{[deps]base}
{[deps]py3}
{[deps]dj18}
psycopg2==2.5.3
setenv =
DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
[testenv:py33-dj18-sqlite]
basepython=python3.3
deps =
{[deps]base}
{[deps]py3}
{[deps]dj18}
setenv =
DATABASE_ENGINE=django.db.backends.sqlite3
[testenv:py34-dj18-postgres]
basepython=python3.4
deps =
{[deps]base}
{[deps]py3}
{[deps]dj18}
psycopg2==2.5.3
setenv =
DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
[testenv:py34-dj18-sqlite]
basepython=python3.4
deps =
{[deps]base}
{[deps]py3}
{[deps]dj18}
setenv =
DATABASE_ENGINE=django.db.backends.sqlite3

View file

@ -143,9 +143,6 @@ TEMPLATE_CONTEXT_PROCESSORS = global_settings.TEMPLATE_CONTEXT_PROCESSORS + (
# Wagtail settings
LOGIN_URL = 'wagtailadmin_login'
LOGIN_REDIRECT_URL = 'wagtailadmin_home'
WAGTAIL_SITE_NAME = "{{ project_name }}"
# Use Elasticsearch as the search backend for extra performance and better search results:

View file

@ -495,6 +495,11 @@ class TaggedPageTag(TaggedItemBase):
class TaggedPage(Page):
tags = ClusterTaggableManager(through=TaggedPageTag, blank=True)
TaggedPage.content_panels = [
FieldPanel('title', classname="full title"),
FieldPanel('tags'),
]
class PageChooserModel(models.Model):
page = models.ForeignKey('wagtailcore.Page', help_text='help text')

View file

@ -102,9 +102,6 @@ PASSWORD_HASHERS = (
COMPRESS_ENABLED = False # disable compression so that we can run tests on the content of the compress tag
LOGIN_REDIRECT_URL = 'wagtailadmin_home'
LOGIN_URL = 'wagtailadmin_login'
WAGTAILSEARCH_BACKENDS = {
'default': {

View file

@ -1,6 +1,5 @@
from contextlib import contextmanager
import warnings
import threading
from django.contrib.auth import get_user_model
from django.utils import six
@ -29,34 +28,3 @@ class WagtailTestUtils(object):
for w in warning_list:
if not issubclass(w.category, DeprecationWarning):
warnings.showwarning(message=w.message, category=w.category, filename=w.filename, lineno=w.lineno, file=w.file, line=w.line)
# from http://www.caktusgroup.com/blog/2009/05/26/testing-django-views-for-concurrency-issues/
def test_concurrently(times):
"""
Add this decorator to small pieces of code that you want to test
concurrently to make sure they don't raise exceptions when run at the
same time. E.g., some Django views that do a SELECT and then a subsequent
INSERT might fail when the INSERT assumes that the data has not changed
since the SELECT.
"""
def test_concurrently_decorator(test_func):
def wrapper(*args, **kwargs):
exceptions = []
def call_test_func():
try:
test_func(*args, **kwargs)
except Exception as e:
exceptions.append(e)
raise
threads = []
for i in range(times):
threads.append(threading.Thread(target=call_test_func))
for t in threads:
t.start()
for t in threads:
t.join()
if exceptions:
raise Exception('test_concurrently intercepted %s exceptions: %s' % (len(exceptions), exceptions))
return wrapper
return test_concurrently_decorator

View file

@ -0,0 +1,9 @@
def decorate_urlpatterns(urlpatterns, decorator):
for pattern in urlpatterns:
if hasattr(pattern, 'url_patterns'):
decorate_urlpatterns(pattern.url_patterns, decorator)
if hasattr(pattern, '_callback'):
pattern._callback = decorator(pattern.callback)
return urlpatterns

View file

@ -137,10 +137,6 @@ def extract_panel_definitions_from_model_class(model, exclude=None):
return panels
def set_page_edit_handler(page_class, handlers):
page_class.handlers = handlers
class EditHandler(object):
"""
Abstract class providing sensible default behaviours for objects implementing
@ -153,11 +149,17 @@ class EditHandler(object):
def widget_overrides(cls):
return {}
# return list of formset names that this EditHandler requires to be present
# as children of the ClusterForm
# return list of fields that this EditHandler expects to find on the form
@classmethod
def required_fields(cls):
return []
# return a dict of formsets that this EditHandler requires to be present
# as children of the ClusterForm; the dict is a mapping from relation name
# to parameters to be passed as part of get_form_for_model's 'formsets' kwarg
@classmethod
def required_formsets(cls):
return []
return {}
# return any HTML that needs to be output on the edit page once per edit handler definition.
# Typically this will be used to define snippets of HTML within <script type="text/x-template"></script> blocks
@ -175,6 +177,7 @@ class EditHandler(object):
if cls._form_class is None:
cls._form_class = get_form_for_model(
model,
fields=cls.required_fields(),
formsets=cls.required_formsets(), widgets=cls.widget_overrides())
return cls._form_class
@ -228,19 +231,16 @@ class EditHandler(object):
# by default, assume that the subclass provides a catch-all render() method
return self.render()
def rendered_fields(self):
"""
return a list of the fields of the passed form which are rendered by this
EditHandler.
"""
return []
def render_missing_fields(self):
"""
Helper function: render all of the fields of the form that are not accounted for
in rendered_fields
Helper function: render all of the fields that are defined on the form but not "claimed" by
any panels via required_fields. These fields are most likely to be hidden fields introduced
by the forms framework itself, such as ORDER / DELETE fields on formset members.
(If they aren't actually hidden fields, then they will appear as ugly unstyled / label-less fields
outside of the panel furniture. But there's not much we can do about that.)
"""
rendered_fields = self.rendered_fields()
rendered_fields = self.required_fields()
missing_fields_html = [
text_type(self.form[field_name])
for field_name in self.form.fields
@ -251,8 +251,8 @@ class EditHandler(object):
def render_form_content(self):
"""
Render this as an 'object', along with any unaccounted-for fields to make this
a valid submittable form
Render this as an 'object', ensuring that all fields necessary for a valid form
submission are included
"""
return mark_safe(self.render_as_object() + self.render_missing_fields())
@ -275,14 +275,26 @@ class BaseCompositeEditHandler(EditHandler):
return cls._widget_overrides
_required_fields = None
@classmethod
def required_fields(cls):
if cls._required_fields is None:
fields = []
for handler_class in cls.children:
fields.extend(handler_class.required_fields())
cls._required_fields = fields
return cls._required_fields
_required_formsets = None
@classmethod
def required_formsets(cls):
if cls._required_formsets is None:
formsets = []
formsets = {}
for handler_class in cls.children:
formsets.extend(handler_class.required_formsets())
formsets.update(handler_class.required_formsets())
cls._required_formsets = formsets
return cls._required_formsets
@ -304,43 +316,56 @@ class BaseCompositeEditHandler(EditHandler):
'self': self
}))
def rendered_fields(self):
result = []
for handler in self.children:
result += handler.rendered_fields()
return result
class BaseTabbedInterface(BaseCompositeEditHandler):
template = "wagtailadmin/edit_handlers/tabbed_interface.html"
def TabbedInterface(children):
return type(str('_TabbedInterface'), (BaseTabbedInterface,), {'children': children})
class TabbedInterface(object):
def __init__(self, children):
self.children = children
def bind_to_model(self, model):
return type(str('_TabbedInterface'), (BaseTabbedInterface,), {
'model': model,
'children': [child.bind_to_model(model) for child in self.children],
})
class BaseObjectList(BaseCompositeEditHandler):
template = "wagtailadmin/edit_handlers/object_list.html"
def ObjectList(children, heading="", classname=""):
return type(str('_ObjectList'), (BaseObjectList,), {
'children': children,
'heading': heading,
'classname': classname
})
class ObjectList(object):
def __init__(self, children, heading="", classname=""):
self.children = children
self.heading = heading
self.classname = classname
def bind_to_model(self, model):
return type(str('_ObjectList'), (BaseObjectList,), {
'model': model,
'children': [child.bind_to_model(model) for child in self.children],
'heading': self.heading,
'classname': self.classname,
})
class BaseFieldRowPanel(BaseCompositeEditHandler):
template = "wagtailadmin/edit_handlers/field_row_panel.html"
def FieldRowPanel(children, classname=""):
return type(str('_FieldRowPanel'), (BaseFieldRowPanel,), {
'children': children,
'classname': classname,
})
class FieldRowPanel(object):
def __init__(self, children, classname=""):
self.children = children
self.classname = classname
def bind_to_model(self, model):
return type(str('_FieldRowPanel'), (BaseFieldRowPanel,), {
'model': model,
'children': [child.bind_to_model(model) for child in self.children],
'classname': self.classname,
})
class BaseMultiFieldPanel(BaseCompositeEditHandler):
@ -353,12 +378,19 @@ class BaseMultiFieldPanel(BaseCompositeEditHandler):
return classes
def MultiFieldPanel(children, heading="", classname=""):
return type(str('_MultiFieldPanel'), (BaseMultiFieldPanel,), {
'children': children,
'heading': heading,
'classname': classname,
})
class MultiFieldPanel(object):
def __init__(self, children, heading="", classname=""):
self.children = children
self.heading = heading
self.classname = classname
def bind_to_model(self, model):
return type(str('_MultiFieldPanel'), (BaseMultiFieldPanel,), {
'model': model,
'children': [child.bind_to_model(model) for child in self.children],
'heading': self.heading,
'classname': self.classname,
})
class BaseFieldPanel(EditHandler):
@ -413,30 +445,43 @@ class BaseFieldPanel(EditHandler):
context.update(extra_context)
return mark_safe(render_to_string(self.field_template, context))
def rendered_fields(self):
@classmethod
def required_fields(self):
return [self.field_name]
def FieldPanel(field_name, classname="", widget=None):
base = {
'field_name': field_name,
'classname': classname,
}
class FieldPanel(object):
def __init__(self, field_name, classname="", widget=None):
self.field_name = field_name
self.classname = classname
self.widget = widget
if widget:
base['widget'] = widget
def bind_to_model(self, model):
base = {
'model': model,
'field_name': self.field_name,
'classname': self.classname,
}
return type(str('_FieldPanel'), (BaseFieldPanel,), base)
if self.widget:
base['widget'] = self.widget
return type(str('_FieldPanel'), (BaseFieldPanel,), base)
class BaseRichTextFieldPanel(BaseFieldPanel):
pass
def RichTextFieldPanel(field_name):
return type(str('_RichTextFieldPanel'), (BaseRichTextFieldPanel,), {
'field_name': field_name,
})
class RichTextFieldPanel(object):
def __init__(self, field_name):
self.field_name = field_name
def bind_to_model(self, model):
return type(str('_RichTextFieldPanel'), (BaseRichTextFieldPanel,), {
'model': model,
'field_name': self.field_name,
})
class BaseChooserPanel(BaseFieldPanel):
@ -512,11 +557,17 @@ class BasePageChooserPanel(BaseChooserPanel):
return super(BasePageChooserPanel, self).render_as_field(show_help_text, context)
def PageChooserPanel(field_name, page_type=None):
return type(str('_PageChooserPanel'), (BasePageChooserPanel,), {
'field_name': field_name,
'page_type': page_type,
})
class PageChooserPanel(object):
def __init__(self, field_name, page_type=None):
self.field_name = field_name
self.page_type = page_type
def bind_to_model(self, model):
return type(str('_PageChooserPanel'), (BasePageChooserPanel,), {
'model': model,
'field_name': self.field_name,
'page_type': self.page_type,
})
class BaseInlinePanel(EditHandler):
@ -535,21 +586,19 @@ class BaseInlinePanel(EditHandler):
def get_child_edit_handler_class(cls):
if cls._child_edit_handler_class is None:
panels = cls.get_panel_definitions()
cls._child_edit_handler_class = MultiFieldPanel(panels, heading=cls.heading)
cls._child_edit_handler_class = MultiFieldPanel(panels, heading=cls.heading).bind_to_model(cls.related.model)
return cls._child_edit_handler_class
@classmethod
def required_formsets(cls):
return [cls.relation_name]
@classmethod
def widget_overrides(cls):
overrides = cls.get_child_edit_handler_class().widget_overrides()
if overrides:
return {cls.relation_name: overrides}
else:
return {}
child_edit_handler_class = cls.get_child_edit_handler_class()
return {
cls.relation_name: {
'fields': child_edit_handler_class.required_fields(),
'widgets': child_edit_handler_class.widget_overrides(),
}
}
def __init__(self, instance=None, form=None):
super(BaseInlinePanel, self).__init__(instance=instance, form=form)
@ -601,15 +650,24 @@ class BaseInlinePanel(EditHandler):
}))
def InlinePanel(base_model, relation_name, panels=None, label='', help_text=''):
rel = getattr(base_model, relation_name).related
return type(str('_InlinePanel'), (BaseInlinePanel,), {
'relation_name': relation_name,
'related': rel,
'panels': panels,
'heading': label,
'help_text': help_text, # TODO: can we pick this out of the foreign key definition as an alternative? (with a bit of help from the inlineformset object, as we do for label/heading)
})
class InlinePanel(object):
def __init__(self, base_model, relation_name, panels=None, label='', help_text=''):
# the base_model param is now redundant; we set up relations based on the model passed to
# bind_to_model instead
self.relation_name = relation_name
self.panels = panels
self.label = label
self.help_text = help_text
def bind_to_model(self, model):
return type(str('_InlinePanel'), (BaseInlinePanel,), {
'model': model,
'relation_name': self.relation_name,
'related': getattr(model, self.relation_name).related,
'panels': self.panels,
'heading': self.label,
'help_text': self.help_text, # TODO: can we pick this out of the foreign key definition as an alternative? (with a bit of help from the inlineformset object, as we do for label/heading)
})
# This allows users to include the publishing panel in their own per-model override
@ -668,8 +726,14 @@ class BaseStreamFieldPanel(BaseFieldPanel):
def html_declarations(cls):
return cls.block_def.all_html_declarations()
def StreamFieldPanel(field_name, block_types):
return type(str('_StreamFieldPanel'), (BaseStreamFieldPanel,), {
'field_name': field_name,
'block_def': StreamBlock(block_types),
})
class StreamFieldPanel(object):
def __init__(self, field_name, block_types):
self.field_name = field_name
self.block_types = block_types
def bind_to_model(self, model):
return type(str('_StreamFieldPanel'), (BaseStreamFieldPanel,), {
'model': model,
'field_name': self.field_name,
'block_def': StreamBlock(self.block_types),
})

View file

@ -117,7 +117,15 @@ function initDateTimeChooser(id) {
function initTagField(id, autocompleteUrl) {
$('#' + id).tagit({
autocomplete: {source: autocompleteUrl}
autocomplete: {source: autocompleteUrl},
preprocessTag: function(val) {
// Double quote a tag if it contains a space
// and if it isn't already quoted.
if (val && val[0] != '"' && val.indexOf(' ') > -1) {
return '"' + val + '"';
}
return val;
}
});
}
@ -340,13 +348,19 @@ $(function() {
});
/* Set up behaviour of preview button */
var previewWindow = null;
$('.action-preview').click(function(e) {
e.preventDefault();
var $this = $(this);
var previewWindow = window.open($this.data('placeholder'), $this.data('windowname'));
if(previewWindow){
previewWindow.close();
}
previewWindow = window.open($this.data('placeholder'), $this.data('windowname'));
if(/MSIE/.test(navigator.userAgent)){
// If IE, load contents immediately without fancy effects
submitPreview.call($this, false);
} else {
previewWindow.onload = function(){
@ -355,16 +369,16 @@ $(function() {
}
function submitPreview(enhanced){
var previewDoc = previewWindow.document;
$.ajax({
type: "POST",
url: $this.data('action'),
data: $('#page-edit-form').serialize(),
success: function(data, textStatus, request) {
if (request.getResponseHeader('X-Wagtail-Preview') == 'ok') {
var pdoc = previewWindow.document;
if(enhanced){
var frame = pdoc.getElementById('preview-frame');
var frame = previewDoc.getElementById('preview-frame');
frame = frame.contentWindow || frame.contentDocument.document || frame.contentDocument;
frame.document.open();
@ -372,14 +386,15 @@ $(function() {
frame.document.close();
var hideTimeout = setTimeout(function(){
pdoc.getElementById('loading-spinner-wrapper').className += 'remove';
previewDoc.getElementById('loading-spinner-wrapper').className += ' remove';
clearTimeout(hideTimeout);
}) // just enough to give effect without adding discernible slowness
} else {
pdoc.open();
pdoc.write(data);
pdoc.close()
previewDoc.open();
previewDoc.write(data);
previewDoc.close()
}
} else {
previewWindow.close();
document.open();
@ -394,9 +409,9 @@ $(function() {
developers can debug template errors. (On a production site, we'd
typically be serving a friendly custom 500 page anyhow.) */
previewWindow.document.open();
previewWindow.document.write(xhr.responseText);
previewWindow.document.close();
previewDoc.open();
previewDoc.write(xhr.responseText);
previewDoc.close();
}
});

View file

@ -25,9 +25,9 @@ html,body {
font-size:0em;
z-index:999999;
background:white;
@include transition(all 0.3s ease);
&.remove{
@include transition(all 0.3s ease);
bottom:-100%;
}

View file

@ -1,3 +1,3 @@
{% load i18n %}
{% trans "Please follow the link below to reset your password" %}
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
{{ protocol }}://{{ domain }}{% url 'wagtailadmin_password_reset_confirm' uidb64=uid token=token %}

View file

@ -4,7 +4,12 @@
{% block furniture %}
<div class="nav-wrapper">
<div class="inner">
<a href="{% url 'wagtailadmin_home' %}" class="logo" title="Wagtail v.{% wagtail_version %}"><img src="{{ STATIC_URL }}wagtailadmin/images/wagtail-logo.svg" alt="Wagtail" width="80" /><span>{% trans "Dashboard" %}</span></a>
<a href="{% url 'wagtailadmin_home' %}" class="logo" title="Wagtail v.{% wagtail_version %}">
{% block branding_logo %}
<img src="{{ STATIC_URL }}wagtailadmin/images/wagtail-logo.svg" alt="Wagtail" width="80" />
{% endblock %}
<span>{% trans "Dashboard" %}</span>
</a>
<form class="nav-search" action="{% url 'wagtailadmin_pages_search' %}" method="get">
<div>
@ -38,4 +43,4 @@
{% block content %}{% endblock %}
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -19,7 +19,7 @@
</div>
{% endif %}
<div class="col9">
<h1>{% blocktrans %}Welcome to the {{ site_name }} Wagtail CMS{% endblocktrans %}</h1>
<h1>{% block branding_welcome %}{% blocktrans %}Welcome to the {{ site_name }} Wagtail CMS{% endblocktrans %}{% endblock %}</h1>
<h2>{{ user.get_full_name|default:user.get_username }}</h2>
</div>
</div>

View file

@ -22,10 +22,12 @@
<form action="{% url 'wagtailadmin_login' %}" method="post" autocomplete="off">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}" />
<h1>{% trans "Sign in to Wagtail" %}</h1>
{% url 'wagtailadmin_home' as home_url %}
<input type="hidden" name="next" value="{{ next|default:home_url }}" />
<h1>{% block branding_login %}{% trans "Sign in to Wagtail" %}{% endblock %}</h1>
<ul class="fields">
<li class="full">
<div class="field iconfield">
@ -43,7 +45,7 @@
</div>
</div>
{% if show_password_reset %}
<p class="help"><a href="{% url 'django.contrib.auth.views.password_reset' %}">{% trans "Forgotten it?" %}</a></p>
<p class="help"><a href="{% url 'wagtailadmin_password_reset' %}">{% trans "Forgotten it?" %}</a></p>
{% endif %}
</li>
{% comment %}

View file

@ -35,11 +35,13 @@ class TestAuthentication(TestCase, WagtailTestUtils):
user = get_user_model().objects.create_superuser(username='test', email='test@email.com', password='password')
# Post credentials to the login page
post_data = {
response = self.client.post(reverse('wagtailadmin_login'), {
'username': 'test',
'password': 'password',
}
response = self.client.post(reverse('wagtailadmin_login'), post_data)
# NOTE: This is set using a hidden field in reality
'next': reverse('wagtailadmin_home'),
})
# Check that the user was redirected to the dashboard
self.assertRedirects(response, reverse('wagtailadmin_home'))
@ -299,7 +301,7 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
This tests that the password reset view returns a password reset page
"""
# Get password reset page
response = self.client.get(reverse('password_reset'))
response = self.client.get(reverse('wagtailadmin_password_reset'))
# Check that the user recieved a password reset page
self.assertEqual(response.status_code, 200)
@ -314,10 +316,10 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
post_data = {
'email': 'test@email.com',
}
response = self.client.post(reverse('password_reset'), post_data)
response = self.client.post(reverse('wagtailadmin_password_reset'), post_data)
# Check that the user was redirected to the done page
self.assertRedirects(response, reverse('password_reset_done'))
self.assertRedirects(response, reverse('wagtailadmin_password_reset_done'))
# Check that a password reset email was sent to the user
self.assertEqual(len(mail.outbox), 1)
@ -332,7 +334,7 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
post_data = {
'email': 'unknown@email.com',
}
response = self.client.post(reverse('password_reset'), post_data)
response = self.client.post(reverse('wagtailadmin_password_reset'), post_data)
# Check that the user wasn't redirected
self.assertEqual(response.status_code, 200)
@ -352,7 +354,7 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
post_data = {
'email': 'Hello world!',
}
response = self.client.post(reverse('password_reset'), post_data)
response = self.client.post(reverse('wagtailadmin_password_reset'), post_data)
# Check that the user wasn't redirected
self.assertEqual(response.status_code, 200)
@ -387,7 +389,7 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
self.setup_password_reset_confirm_tests()
# Get password reset confirm page
response = self.client.get(reverse('password_reset_confirm', kwargs=self.url_kwargs))
response = self.client.get(reverse('wagtailadmin_password_reset_confirm', kwargs=self.url_kwargs))
# Check that the user recieved a password confirm done page
self.assertEqual(response.status_code, 200)
@ -405,10 +407,10 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
'new_password1': 'newpassword',
'new_password2': 'newpassword',
}
response = self.client.post(reverse('password_reset_confirm', kwargs=self.url_kwargs), post_data)
response = self.client.post(reverse('wagtailadmin_password_reset_confirm', kwargs=self.url_kwargs), post_data)
# Check that the user was redirected to the complete page
self.assertRedirects(response, reverse('password_reset_complete'))
self.assertRedirects(response, reverse('wagtailadmin_password_reset_complete'))
# Check that the password was changed
self.assertTrue(get_user_model().objects.get(username='test').check_password('newpassword'))
@ -425,7 +427,7 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
'new_password1': 'newpassword',
'new_password2': 'badpassword',
}
response = self.client.post(reverse('password_reset_confirm', kwargs=self.url_kwargs), post_data)
response = self.client.post(reverse('wagtailadmin_password_reset_confirm', kwargs=self.url_kwargs), post_data)
# Check that the user wasn't redirected
self.assertEqual(response.status_code, 200)
@ -442,7 +444,7 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
This tests that the password reset done view returns a password reset done page
"""
# Get password reset done page
response = self.client.get(reverse('password_reset_done'))
response = self.client.get(reverse('wagtailadmin_password_reset_done'))
# Check that the user recieved a password reset done page
self.assertEqual(response.status_code, 200)
@ -453,7 +455,7 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
This tests that the password reset complete view returns a password reset complete page
"""
# Get password reset complete page
response = self.client.get(reverse('password_reset_complete'))
response = self.client.get(reverse('wagtailadmin_password_reset_complete'))
# Check that the user recieved a password reset complete page
self.assertEqual(response.status_code, 200)

View file

@ -1,243 +1,384 @@
from mock import MagicMock
from datetime import date
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django import forms
from wagtail.wagtailadmin.edit_handlers import (
get_form_for_model,
extract_panel_definitions_from_model_class,
BaseFieldPanel,
FieldPanel,
WagtailAdminModelForm,
BaseTabbedInterface,
RichTextFieldPanel,
TabbedInterface,
BaseObjectList,
ObjectList,
PageChooserPanel,
InlinePanel,
)
from wagtail.wagtailadmin.widgets import AdminPageChooser
from wagtail.wagtailadmin.widgets import AdminPageChooser, AdminDateInput
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtailcore.models import Page, Site
from wagtail.tests.models import PageChooserModel
from wagtail.tests.models import PageChooserModel, EventPage, EventPageSpeaker
class TestGetFormForModel(TestCase):
class FakeClass(object):
_meta = MagicMock()
def setUp(self):
self.mock_exclude = MagicMock()
def test_get_form_for_model(self):
form = get_form_for_model(self.FakeClass,
fields=[],
exclude=[self.mock_exclude],
formsets=['baz'],
exclude_formsets=['quux'],
widgets=['bacon'])
self.assertEqual(form.Meta.exclude, [self.mock_exclude])
self.assertEqual(form.Meta.formsets, ['baz'])
self.assertEqual(form.Meta.exclude_formsets, ['quux'])
self.assertEqual(form.Meta.widgets, ['bacon'])
EventPageForm = get_form_for_model(EventPage)
form = EventPageForm()
# form should contain a title field (from the base Page)
self.assertEqual(type(form.fields['title']), forms.CharField)
# and 'date_from' from EventPage
self.assertEqual(type(form.fields['date_from']), forms.DateField)
# the widget should be overridden with AdminDateInput as per FORM_FIELD_OVERRIDES
self.assertEqual(type(form.fields['date_from'].widget), AdminDateInput)
# treebeard's 'path' field should be excluded
self.assertNotIn('path', form.fields)
# all child relations become formsets by default
self.assertIn('speakers', form.formsets)
self.assertIn('related_links', form.formsets)
def test_get_form_for_model_with_specific_fields(self):
EventPageForm = get_form_for_model(EventPage, fields=['date_from'], formsets=['speakers'])
form = EventPageForm()
# form should contain date_from but not title
self.assertEqual(type(form.fields['date_from']), forms.DateField)
self.assertEqual(type(form.fields['date_from'].widget), AdminDateInput)
self.assertNotIn('title', form.fields)
# formsets should include speakers but not related_links
self.assertIn('speakers', form.formsets)
self.assertNotIn('related_links', form.formsets)
def test_get_form_for_model_with_excluded_fields(self):
EventPageForm = get_form_for_model(EventPage, exclude=['title'], exclude_formsets=['related_links'])
form = EventPageForm()
# form should contain date_from but not title
self.assertEqual(type(form.fields['date_from']), forms.DateField)
self.assertEqual(type(form.fields['date_from'].widget), AdminDateInput)
self.assertNotIn('title', form.fields)
# 'path' should still be excluded even though it isn't explicitly in the exclude list
self.assertNotIn('path', form.fields)
# formsets should include speakers but not related_links
self.assertIn('speakers', form.formsets)
self.assertNotIn('related_links', form.formsets)
def test_get_form_for_model_with_widget_overides_by_class(self):
EventPageForm = get_form_for_model(EventPage, widgets={'date_from': forms.PasswordInput})
form = EventPageForm()
self.assertEqual(type(form.fields['date_from']), forms.DateField)
self.assertEqual(type(form.fields['date_from'].widget), forms.PasswordInput)
def test_get_form_for_model_with_widget_overides_by_instance(self):
EventPageForm = get_form_for_model(EventPage, widgets={'date_from': forms.PasswordInput()})
form = EventPageForm()
self.assertEqual(type(form.fields['date_from']), forms.DateField)
self.assertEqual(type(form.fields['date_from'].widget), forms.PasswordInput)
class TestExtractPanelDefinitionsFromModelClass(TestCase):
def test_can_extract_panels(self):
mock = MagicMock()
mock.panels = 'foo'
result = extract_panel_definitions_from_model_class(mock)
self.assertEqual(result, 'foo')
def test_can_extract_panel_property(self):
# A class with a 'panels' property defined should return that list
result = extract_panel_definitions_from_model_class(EventPageSpeaker)
self.assertEqual(len(result), 4)
#print repr(result)
self.assertTrue(any([isinstance(panel, ImageChooserPanel) for panel in result]))
def test_exclude(self):
panels = extract_panel_definitions_from_model_class(Site, exclude=['hostname'])
for panel in panels:
self.assertNotEqual(panel.field_name, 'hostname')
def test_extracted_objects_are_panels(self):
panels = extract_panel_definitions_from_model_class(Page)
for panel in panels:
self.assertTrue(issubclass(panel, BaseFieldPanel))
def test_can_build_panel_list(self):
# EventPage has no 'panels' definition, so one should be derived from the field list
panels = extract_panel_definitions_from_model_class(EventPage)
self.assertTrue(any([
isinstance(panel, FieldPanel) and panel.field_name == 'date_from'
for panel in panels
]))
# returned panel types should respect modelfield.get_panel() - used on RichTextField
self.assertTrue(any([
isinstance(panel, RichTextFieldPanel) and panel.field_name == 'body'
for panel in panels
]))
# treebeard fields should be excluded
self.assertFalse(any([
panel.field_name == 'path'
for panel in panels
]))
class TestTabbedInterface(TestCase):
class FakeChild(object):
class FakeGrandchild(object):
def rendered_fields(self):
return ["rendered fields"]
def widget_overrides(self):
return {'foo': 'bar'}
def required_formsets(self):
return {'baz': 'quux'}
def __call__(self, *args, **kwargs):
fake_grandchild = self.FakeGrandchild()
return fake_grandchild
def setUp(self):
fake_child = self.FakeChild()
self.TabbedInterfaceClass = TabbedInterface([fake_child])
self.tabbed_interface = self.TabbedInterfaceClass(instance=True,
form=True)
# a custom tabbed interface for EventPage
self.EventPageTabbedInterface = TabbedInterface([
ObjectList([
FieldPanel('title', widget=forms.Textarea),
FieldPanel('date_from'),
FieldPanel('date_to'),
], heading='Event details', classname="shiny"),
ObjectList([
InlinePanel(EventPage, 'speakers', label="Speakers"),
], heading='Speakers'),
]).bind_to_model(EventPage)
def test_tabbed_interface(self):
self.assertTrue(issubclass(self.TabbedInterfaceClass,
BaseTabbedInterface))
def test_get_form_class(self):
EventPageForm = self.EventPageTabbedInterface.get_form_class(EventPage)
form = EventPageForm()
def test_widget_overrides(self):
result = self.tabbed_interface.widget_overrides()
self.assertEqual(result, {'foo': 'bar'})
# form must include the 'speakers' formset required by the speakers InlinePanel
self.assertIn('speakers', form.formsets)
def test_required_formsets(self):
result = self.tabbed_interface.required_formsets()
self.assertEqual(result, ['baz'])
# form must respect any overridden widgets
self.assertEqual(type(form.fields['title'].widget), forms.Textarea)
def test_render(self):
result = self.tabbed_interface.render()
self.assertIn('<div class="tab-content">', result)
EventPageForm = self.EventPageTabbedInterface.get_form_class(EventPage)
event = EventPage(title='Abergavenny sheepdog trials')
form = EventPageForm(instance=event)
def test_rendered_fields(self):
result = self.tabbed_interface.rendered_fields()
self.assertEqual(result, ["rendered fields"])
tabbed_interface = self.EventPageTabbedInterface(
instance=event,
form=form
)
result = tabbed_interface.render()
# result should contain tab buttons
self.assertIn('<a href="#event-details" class="active">Event details</a>', result)
self.assertIn('<a href="#speakers" class="">Speakers</a>', result)
# result should contain tab panels
self.assertIn('<div class="tab-content">', result)
self.assertIn('<section id="event-details" class="shiny active">', result)
self.assertIn('<section id="speakers" class=" ">', result)
# result should contain rendered content from descendants
self.assertIn('Abergavenny sheepdog trials</textarea>', result)
# this result should not include fields that are not covered by the panel definition
self.assertNotIn('signup_link', result)
def test_required_fields(self):
# required_fields should report the set of form fields to be rendered recursively by children of TabbedInterface
result = set(self.EventPageTabbedInterface.required_fields())
self.assertEqual(result, set(['title', 'date_from', 'date_to']))
def test_render_form_content(self):
EventPageForm = self.EventPageTabbedInterface.get_form_class(EventPage)
event = EventPage(title='Abergavenny sheepdog trials')
form = EventPageForm(instance=event)
tabbed_interface = self.EventPageTabbedInterface(
instance=event,
form=form
)
result = tabbed_interface.render_form_content()
# rendered output should contain field content as above
self.assertIn('Abergavenny sheepdog trials</textarea>', result)
# rendered output should NOT include fields that are in the model but not represented
# in the panel definition
self.assertNotIn('signup_link', result)
class TestObjectList(TestCase):
def test_object_list(self):
object_list = ObjectList(['foo'])
self.assertTrue(issubclass(object_list, BaseObjectList))
def setUp(self):
# a custom ObjectList for EventPage
self.EventPageObjectList = ObjectList([
FieldPanel('title', widget=forms.Textarea),
FieldPanel('date_from'),
FieldPanel('date_to'),
InlinePanel(EventPage, 'speakers', label="Speakers"),
], heading='Event details', classname="shiny").bind_to_model(EventPage)
def test_get_form_class(self):
EventPageForm = self.EventPageObjectList.get_form_class(EventPage)
form = EventPageForm()
# form must include the 'speakers' formset required by the speakers InlinePanel
self.assertIn('speakers', form.formsets)
# form must respect any overridden widgets
self.assertEqual(type(form.fields['title'].widget), forms.Textarea)
def test_render(self):
EventPageForm = self.EventPageObjectList.get_form_class(EventPage)
event = EventPage(title='Abergavenny sheepdog trials')
form = EventPageForm(instance=event)
object_list = self.EventPageObjectList(
instance=event,
form=form
)
result = object_list.render()
# result should contain ObjectList furniture
self.assertIn('<ul class="objects">', result)
# result should contain h2 headings for children
self.assertIn('<h2>Start date</h2>', result)
# result should contain rendered content from descendants
self.assertIn('Abergavenny sheepdog trials</textarea>', result)
# this result should not include fields that are not covered by the panel definition
self.assertNotIn('signup_link', result)
class TestFieldPanel(TestCase):
class FakeClass(object):
required = False
widget = 'fake widget'
class FakeField(object):
label = 'label'
help_text = 'help text'
errors = ['errors']
id_for_label = 'id for label'
class FakeForm(dict):
def __init__(self, *args, **kwargs):
self.fields = self.fields_iterator()
def fields_iterator(self):
for i in self:
yield i
def setUp(self):
fake_field = self.FakeField()
fake_field.field = self.FakeClass()
self.field_panel = FieldPanel('barbecue', 'snowman')(
instance=True,
form={'barbecue': fake_field})
self.EventPageForm = get_form_for_model(EventPage, formsets = [])
self.event = EventPage(title='Abergavenny sheepdog trials',
date_from=date(2014, 7, 20), date_to=date(2014, 7, 21))
self.EndDatePanel = FieldPanel('date_to', classname='full-width').bind_to_model(EventPage)
def test_render_as_object(self):
result = self.field_panel.render_as_object()
self.assertIn('<legend>label</legend>',
result)
self.assertIn('<p class="error-message">',
result)
form = self.EventPageForm(
{'title': 'Pontypridd sheepdog trials', 'date_from': '2014-07-20', 'date_to': '2014-07-22'},
instance=self.event)
form.is_valid()
field_panel = self.EndDatePanel(
instance=self.event,
form=form
)
result = field_panel.render_as_object()
# check that label appears in the 'object' wrapper as well as the field
self.assertIn('<legend>End date</legend>', result)
self.assertIn('<label for="id_date_to">End date:</label>', result)
# check that help text is included
self.assertIn('Not required if event is on a single day', result)
# check that the populated form field is included
self.assertIn('value="2014-07-22"', result)
# there should be no errors on this field
self.assertNotIn('<p class="error-message">', result)
def test_render_as_field(self):
field = self.FakeField()
bound_field = self.FakeField()
bound_field.field = field
self.field_panel.bound_field = bound_field
result = self.field_panel.render_as_field()
self.assertIn('<p class="help">help text</p>',
result)
self.assertIn('<span>errors</span>',
result)
form = self.EventPageForm(
{'title': 'Pontypridd sheepdog trials', 'date_from': '2014-07-20', 'date_to': '2014-07-22'},
instance=self.event)
def test_rendered_fields(self):
result = self.field_panel.rendered_fields()
self.assertEqual(result, ['barbecue'])
form.is_valid()
def test_field_type(self):
fake_object = self.FakeClass()
another_fake_object = self.FakeClass()
fake_object.field = another_fake_object
self.field_panel.bound_field = fake_object
self.assertEqual(self.field_panel.field_type(), 'fake_class')
field_panel = self.EndDatePanel(
instance=self.event,
form=form
)
result = field_panel.render_as_field()
def test_widget_overrides(self):
result = FieldPanel('barbecue', 'snowman').widget_overrides()
self.assertEqual(result, {})
# check that label is output in the 'field' style
self.assertIn('<label for="id_date_to">End date:</label>', result)
self.assertNotIn('<legend>End date</legend>', result)
def test_required_formsets(self):
result = FieldPanel('barbecue', 'snowman').required_formsets()
self.assertEqual(result, [])
# check that help text is included
self.assertIn('Not required if event is on a single day', result)
def test_get_form_class(self):
result = FieldPanel('barbecue', 'snowman').get_form_class(Page)
self.assertTrue(issubclass(result, WagtailAdminModelForm))
# check that the populated form field is included
self.assertIn('value="2014-07-22"', result)
def test_render_missing_fields(self):
fake_form = self.FakeForm()
fake_form["foo"] = "bar"
self.field_panel.form = fake_form
self.assertEqual(self.field_panel.render_missing_fields(), "bar")
# there should be no errors on this field
self.assertNotIn('<p class="error-message">', result)
def test_render_form_content(self):
fake_form = self.FakeForm()
fake_form["foo"] = "bar"
self.field_panel.form = fake_form
self.assertIn("bar", self.field_panel.render_form_content())
def test_required_fields(self):
result = self.EndDatePanel.required_fields()
self.assertEqual(result, ['date_to'])
def test_error_message_is_rendered(self):
form = self.EventPageForm(
{'title': 'Pontypridd sheepdog trials', 'date_from': '2014-07-20', 'date_to': '2014-07-33'},
instance=self.event)
form.is_valid()
field_panel = self.EndDatePanel(
instance=self.event,
form=form
)
result = field_panel.render_as_field()
self.assertIn('<p class="error-message">', result)
self.assertIn('<span>Enter a valid date.</span>', result)
class TestPageChooserPanel(TestCase):
fixtures = ['test.json']
def setUp(self):
model = PageChooserModel
self.chosen_page = Page.objects.get(pk=2)
test_instance = model.objects.create(page=self.chosen_page)
self.dotted_model = model._meta.app_label + '.' + model._meta.model_name
model = PageChooserModel # a model with a foreign key to Page which we want to render as a page chooser
self.page_chooser_panel_class = PageChooserPanel('page', model)
# a PageChooserPanel class that works on PageChooserModel's 'page' field
self.MyPageChooserPanel = PageChooserPanel('page', 'tests.EventPage').bind_to_model(PageChooserModel)
form_class = get_form_for_model(model, widgets=self.page_chooser_panel_class.widget_overrides())
form = form_class(instance=test_instance)
form.errors['page'] = form.error_class(['errors'])
# build a form class containing the fields that MyPageChooserPanel wants
self.PageChooserForm = self.MyPageChooserPanel.get_form_class(PageChooserModel)
self.page_chooser_panel = self.page_chooser_panel_class(instance=test_instance,
form=form)
# a test instance of PageChooserModel, pointing to the 'christmas' page
self.christmas_page = Page.objects.get(slug='christmas')
self.events_index_page = Page.objects.get(slug='events')
self.test_instance = model.objects.create(page=self.christmas_page)
self.form = self.PageChooserForm(instance=self.test_instance)
# self.form.errors['page'] = self.form.error_class(['errors']) # FIXME: wat
self.page_chooser_panel = self.MyPageChooserPanel(instance=self.test_instance,
form=self.form)
def test_page_chooser_uses_correct_widget(self):
self.assertEqual(type(self.form.fields['page'].widget), AdminPageChooser)
def test_render_js_init(self):
result = self.page_chooser_panel.render_as_field()
self.assertIn(
'createPageChooser("{id}", "{model}", {parent});'.format(
id="id_page", model=self.dotted_model, parent=self.chosen_page.get_parent().id),
result)
expected_js = 'createPageChooser("{id}", "{model}", {parent});'.format(
id="id_page", model="tests.eventpage", parent=self.events_index_page.id)
self.assertIn(expected_js, result)
def test_get_chosen_item(self):
result = self.page_chooser_panel.get_chosen_item()
self.assertEqual(result, self.chosen_page)
self.assertEqual(result, self.christmas_page)
def test_render_as_field(self):
result = self.page_chooser_panel.render_as_field()
self.assertIn('<p class="help">help text</p>', result)
self.assertIn('<span>errors</span>', result)
def test_widget_overrides(self):
result = self.page_chooser_panel.widget_overrides()
self.assertIsInstance(result['page'], AdminPageChooser)
def test_render_error(self):
form = self.PageChooserForm({'page': ''}, instance=self.test_instance)
self.assertFalse(form.is_valid())
page_chooser_panel = self.MyPageChooserPanel(instance=self.test_instance,
form=form)
self.assertIn('<span>This field is required.</span>', page_chooser_panel.render_as_field())
def test_target_content_type(self):
result = PageChooserPanel(
'barbecue',
'wagtailcore.site'
).target_content_type()
).bind_to_model(PageChooserModel).target_content_type()
self.assertEqual(result.name, 'site')
def test_target_content_type_malformed_type(self):
result = PageChooserPanel(
'barbecue',
'snowman'
)
).bind_to_model(PageChooserModel)
self.assertRaises(ImproperlyConfigured,
result.target_content_type)
@ -245,121 +386,88 @@ class TestPageChooserPanel(TestCase):
result = PageChooserPanel(
'barbecue',
'snowman.lorry'
)
).bind_to_model(PageChooserModel)
self.assertRaises(ImproperlyConfigured,
result.target_content_type)
class TestInlinePanel(TestCase):
class FakeField(object):
class FakeFormset(object):
class FakeForm(object):
class FakeInstance(object):
def __repr__(self):
return 'fake instance'
fields = {'DELETE': MagicMock(),
'ORDER': MagicMock()}
instance = FakeInstance()
cleaned_data = {
'ORDER': 0,
}
def __repr__(self):
return 'fake form'
forms = [FakeForm()]
empty_form = FakeForm()
can_order = True
def is_valid(self):
return True
label = 'label'
help_text = 'help text'
errors = ['errors']
id_for_label = 'id for label'
formsets = {'formset': FakeFormset()}
class FakeInstance(object):
class FakePage(object):
class FakeParent(object):
id = 1
name = 'fake page'
def get_parent(self):
return self.FakeParent()
def __init__(self):
fake_page = self.FakePage()
self.barbecue = fake_page
class FakePanel(object):
name = 'mock panel'
class FakeChild(object):
def rendered_fields(self):
return ["rendered fields"]
def init(*args, **kwargs):
pass
def __call__(self, *args, **kwargs):
fake_child = self.FakeChild()
return fake_child
def setUp(self):
self.fake_field = self.FakeField()
self.fake_instance = self.FakeInstance()
self.mock_panel = self.FakePanel()
self.mock_model = MagicMock()
self.mock_model.formset.related.model.panels = [self.mock_panel]
def test_get_panel_definitions_no_panels(self):
"""
Check that get_panel_definitions returns the panels set on the model
when no panels are set on the InlinePanel
"""
inline_panel = InlinePanel(self.mock_model, 'formset')(
instance=self.fake_instance,
form=self.fake_field)
result = inline_panel.get_panel_definitions()
self.assertEqual(result[0].name, 'mock panel')
def test_get_panel_definitions(self):
"""
Check that get_panel_definitions returns the panels set on
InlinePanel
"""
other_mock_panel = MagicMock()
other_mock_panel.name = 'other mock panel'
inline_panel = InlinePanel(self.mock_model,
'formset',
panels=[other_mock_panel])(
instance=self.fake_instance,
form=self.fake_field)
result = inline_panel.get_panel_definitions()
self.assertEqual(result[0].name, 'other mock panel')
def test_required_formsets(self):
inline_panel = InlinePanel(self.mock_model, 'formset')(
instance=self.fake_instance,
form=self.fake_field)
self.assertEqual(inline_panel.required_formsets(), ['formset'])
fixtures = ['test.json']
def test_render(self):
inline_panel = InlinePanel(self.mock_model,
'formset',
label='foo')(
instance=self.fake_instance,
form=self.fake_field)
self.assertIn('Add foo', inline_panel.render())
"""
Check that the inline panel renders the panels set on the model
when no 'panels' parameter is passed in the InlinePanel definition
"""
SpeakerInlinePanel = InlinePanel(EventPage, 'speakers', label="Speakers").bind_to_model(EventPage)
EventPageForm = SpeakerInlinePanel.get_form_class(EventPage)
def test_render_js_init(self):
inline_panel = InlinePanel(self.mock_model,
'formset')(
instance=self.fake_instance,
form=self.fake_field)
self.assertIn('var panel = InlinePanel({',
inline_panel.render_js_init())
# SpeakerInlinePanel should instruct the form class to include a 'speakers' formset
self.assertEqual(['speakers'], list(EventPageForm.formsets.keys()))
event_page = EventPage.objects.get(slug='christmas')
form = EventPageForm(instance=event_page)
panel = SpeakerInlinePanel(instance=event_page, form=form)
result = panel.render_as_field()
self.assertIn('<label for="id_speakers-0-first_name">Name:</label>', result)
self.assertIn('value="Father"', result)
self.assertIn('<label for="id_speakers-0-last_name">Surname:</label>', result)
self.assertIn('<label for="id_speakers-0-image">Image:</label>', result)
self.assertIn('value="Choose an image"', result)
# rendered panel must also contain hidden fields for id, DELETE and ORDER
self.assertIn('<input id="id_speakers-0-id" name="speakers-0-id" type="hidden"', result)
self.assertIn('<input id="id_speakers-0-DELETE" name="speakers-0-DELETE" type="hidden"', result)
self.assertIn('<input id="id_speakers-0-ORDER" name="speakers-0-ORDER" type="hidden"', result)
# rendered panel must contain maintenance form for the formset
self.assertIn('<input id="id_speakers-TOTAL_FORMS" name="speakers-TOTAL_FORMS" type="hidden"', result)
# render_js_init must provide the JS initializer
self.assertIn('var panel = InlinePanel({', panel.render_js_init())
def test_render_with_panel_overrides(self):
"""
Check that inline panel renders the panels listed in the InlinePanel definition
where one is specified
"""
SpeakerInlinePanel = InlinePanel(EventPage, 'speakers', label="Speakers", panels=[
FieldPanel('first_name', widget=forms.Textarea),
ImageChooserPanel('image'),
]).bind_to_model(EventPage)
EventPageForm = SpeakerInlinePanel.get_form_class(EventPage)
# SpeakerInlinePanel should instruct the form class to include a 'speakers' formset
self.assertEqual(['speakers'], list(EventPageForm.formsets.keys()))
event_page = EventPage.objects.get(slug='christmas')
form = EventPageForm(instance=event_page)
panel = SpeakerInlinePanel(instance=event_page, form=form)
result = panel.render_as_field()
# rendered panel should contain first_name rendered as a text area, but no last_name field
self.assertIn('<label for="id_speakers-0-first_name">Name:</label>', result)
self.assertIn('Father</textarea>', result)
self.assertNotIn('<label for="id_speakers-0-last_name">Surname:</label>', result)
# test for #338: surname field should not be rendered as a 'stray' label-less field
self.assertNotIn('<input id="id_speakers-0-last_name"', result)
self.assertIn('<label for="id_speakers-0-image">Image:</label>', result)
self.assertIn('value="Choose an image"', result)
# rendered panel must also contain hidden fields for id, DELETE and ORDER
self.assertIn('<input id="id_speakers-0-id" name="speakers-0-id" type="hidden"', result)
self.assertIn('<input id="id_speakers-0-DELETE" name="speakers-0-DELETE" type="hidden"', result)
self.assertIn('<input id="id_speakers-0-ORDER" name="speakers-0-ORDER" type="hidden"', result)
# rendered panel must contain maintenance form for the formset
self.assertIn('<input id="id_speakers-TOTAL_FORMS" name="speakers-TOTAL_FORMS" type="hidden"', result)
# render_js_init must provide the JS initializer
self.assertIn('var panel = InlinePanel({', panel.render_js_init())

View file

@ -186,7 +186,7 @@ class TestPageCreation(TestCase, WagtailTestUtils):
# Should be redirected to edit page
self.assertRedirects(response, reverse('wagtailadmin_pages_edit', args=(page.id, )))
self.assertEqual(page.title, post_data['title'])
self.assertIsInstance(page, SimplePage)
self.assertFalse(page.live)
@ -325,7 +325,7 @@ class TestPageCreation(TestCase, WagtailTestUtils):
# Should be redirected to explorer
self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
self.assertEqual(page.title, post_data['title'])
self.assertIsInstance(page, SimplePage)
self.assertFalse(page.live)
@ -389,6 +389,20 @@ class TestPageCreation(TestCase, WagtailTestUtils):
self.assertTrue(response.context['self'].path.startswith(self.root_page.path))
self.assertEqual(response.context['self'].get_parent(), self.root_page)
def test_whitespace_titles(self):
post_data = {
'title': " ", # Single space on purpose
'content': "Some content",
'slug': 'hello-world',
'action-submit': "Submit",
'seo_title': '\t',
}
response = self.client.post(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)), post_data)
# Check that a form error was raised
self.assertFormError(response, 'form', 'title', "Value cannot be entirely whitespace characters")
self.assertFormError(response, 'form', 'seo_title', "Value cannot be entirely whitespace characters")
class TestPageEdit(TestCase, WagtailTestUtils):
def setUp(self):
@ -439,7 +453,7 @@ class TestPageEdit(TestCase, WagtailTestUtils):
'slug': 'hello-world',
}
response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
# Should be redirected to edit page
self.assertRedirects(response, reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )))
@ -461,7 +475,7 @@ class TestPageEdit(TestCase, WagtailTestUtils):
'slug': 'hello-world',
}
response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
# Shouldn't be redirected
self.assertContains(response, "The page could not be saved as it is locked")
@ -550,7 +564,7 @@ class TestPageEdit(TestCase, WagtailTestUtils):
'action-publish': "Publish",
}
response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
# Should be redirected to explorer
self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
@ -652,7 +666,7 @@ class TestPageEdit(TestCase, WagtailTestUtils):
'action-submit': "Submit",
}
response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
# Should be redirected to explorer
self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))

View file

@ -28,6 +28,15 @@ class TestHome(TestCase, WagtailTestUtils):
response = self.client.get(reverse('wagtailadmin_home') + '?hide-kittens=true')
self.assertNotContains(response, '<a href="http://www.tomroyal.com/teaandkittens/" class="icon icon-kitten" data-fluffy="yes">Kittens!</a>')
def test_never_cache_header(self):
# This tests that wagtailadmins global cache settings have been applied correctly
response = self.client.get(reverse('wagtailadmin_home'))
self.assertIn('private', response['Cache-Control'])
self.assertIn('no-cache', response['Cache-Control'])
self.assertIn('no-store', response['Cache-Control'])
self.assertIn('max-age=0', response['Cache-Control'])
class TestEditorHooks(TestCase, WagtailTestUtils):
def setUp(self):

View file

@ -1,39 +1,14 @@
from django.conf.urls import url
from django.contrib.auth.decorators import permission_required
from django.views.decorators.cache import cache_control
from wagtail.wagtailadmin.forms import PasswordResetForm
from wagtail.wagtailadmin.views import account, chooser, home, pages, tags, userbar, page_privacy
from wagtail.wagtailcore import hooks
from wagtail.utils.urlpatterns import decorate_urlpatterns
urlpatterns = [
# Password reset
url(
r'^password_reset/$', 'django.contrib.auth.views.password_reset', {
'template_name': 'wagtailadmin/account/password_reset/form.html',
'email_template_name': 'wagtailadmin/account/password_reset/email.txt',
'subject_template_name': 'wagtailadmin/account/password_reset/email_subject.txt',
'password_reset_form': PasswordResetForm,
}, name='password_reset'
),
url(
r'^password_reset/done/$', 'django.contrib.auth.views.password_reset_done', {
'template_name': 'wagtailadmin/account/password_reset/done.html'
}, name='password_reset_done'
),
url(
r'^password_reset/confirm/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
'django.contrib.auth.views.password_reset_confirm',
{'template_name': 'wagtailadmin/account/password_reset/confirm.html'},
name='password_reset_confirm',
),
url(
r'^password_reset/complete/$', 'django.contrib.auth.views.password_reset_complete',
{'template_name': 'wagtailadmin/account/password_reset/complete.html'},
name='password_reset_complete'
),
]
urlpatterns += [
url(r'^$', home.home, name='wagtailadmin_home'),
url(r'^failwhale/$', home.error_test, name='wagtailadmin_error_test'),
@ -83,21 +58,10 @@ urlpatterns += [
url(r'^tag-autocomplete/$', tags.autocomplete, name='wagtailadmin_tag_autocomplete'),
url(r'^login/$', account.login, name='wagtailadmin_login'),
url(r'^account/$', account.account, name='wagtailadmin_account'),
url(r'^account/change_password/$', account.change_password, name='wagtailadmin_account_change_password'),
url(r'^account/notification_preferences/$', account.notification_preferences, name='wagtailadmin_account_notification_preferences'),
url(r'^logout/$', account.logout, name='wagtailadmin_logout'),
url(r'^userbar/(\d+)/$', userbar.for_frontend, name='wagtailadmin_userbar_frontend'),
url(r'^userbar/moderation/(\d+)/$', userbar.for_moderation, name='wagtailadmin_userbar_moderation'),
]
# This is here to make sure that 'django.contrib.auth.views.login' is reversed correctly
# It must be placed after 'wagtailadmin_login' to prevent this from being used
urlpatterns += [
url(r'^login/$', 'django.contrib.auth.views.login'),
]
@ -106,3 +70,56 @@ for fn in hooks.get_hooks('register_admin_urls'):
urls = fn()
if urls:
urlpatterns += urls
# Add "wagtailadmin.access_admin" permission check
urlpatterns = decorate_urlpatterns(urlpatterns,
permission_required(
'wagtailadmin.access_admin',
login_url='wagtailadmin_login'
)
)
# These url patterns do not require an authenticated admin user
urlpatterns += [
url(r'^login/$', account.login, name='wagtailadmin_login'),
# These two URLs have the "permission_required" decorator applied directly
# as they need to fail with a 403 error rather than redirect to the login page
url(r'^userbar/(\d+)/$', userbar.for_frontend, name='wagtailadmin_userbar_frontend'),
url(r'^userbar/moderation/(\d+)/$', userbar.for_moderation, name='wagtailadmin_userbar_moderation'),
# Password reset
url(
r'^password_reset/$', 'django.contrib.auth.views.password_reset', {
'template_name': 'wagtailadmin/account/password_reset/form.html',
'email_template_name': 'wagtailadmin/account/password_reset/email.txt',
'subject_template_name': 'wagtailadmin/account/password_reset/email_subject.txt',
'password_reset_form': PasswordResetForm,
'post_reset_redirect': 'wagtailadmin_password_reset_done',
}, name='wagtailadmin_password_reset'
),
url(
r'^password_reset/done/$', 'django.contrib.auth.views.password_reset_done', {
'template_name': 'wagtailadmin/account/password_reset/done.html'
}, name='wagtailadmin_password_reset_done'
),
url(
r'^password_reset/confirm/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
'django.contrib.auth.views.password_reset_confirm', {
'template_name': 'wagtailadmin/account/password_reset/confirm.html',
'post_reset_redirect': 'wagtailadmin_password_reset_complete',
}, name='wagtailadmin_password_reset_confirm',
),
url(
r'^password_reset/complete/$', 'django.contrib.auth.views.password_reset_complete',{
'template_name': 'wagtailadmin/account/password_reset/complete.html'
}, name='wagtailadmin_password_reset_complete'
),
]
# Decorate all views with cache settings to prevent caching
urlpatterns = decorate_urlpatterns(urlpatterns,
cache_control(private=True, no_cache=True, no_store=True, max_age=0)
)

View file

@ -2,7 +2,6 @@ from django.conf import settings
from django.shortcuts import render, redirect
from django.contrib import messages
from django.contrib.auth.forms import SetPasswordForm
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.views import logout as auth_logout, login as auth_login
from django.utils.translation import ugettext as _
from django.views.decorators.debug import sensitive_post_parameters
@ -14,7 +13,6 @@ from wagtail.wagtailusers.models import UserProfile
from wagtail.wagtailcore.models import UserPagePermissionsProxy
@permission_required('wagtailadmin.access_admin')
def account(request):
user_perms = UserPagePermissionsProxy(request.user)
show_notification_preferences = user_perms.can_edit_pages() or user_perms.can_publish_pages()
@ -25,7 +23,6 @@ def account(request):
})
@permission_required('wagtailadmin.access_admin')
def change_password(request):
can_change_password = request.user.has_usable_password()
@ -49,7 +46,6 @@ def change_password(request):
})
@permission_required('wagtailadmin.access_admin')
def notification_preferences(request):
if request.POST:
@ -90,7 +86,7 @@ def login(request):
def logout(request):
response = auth_logout(request, next_page = 'wagtailadmin_login')
response = auth_logout(request, next_page='wagtailadmin_login')
# By default, logging out will generate a fresh sessionid cookie. We want to use the
# absence of sessionid as an indication that front-end pages are being viewed by a

View file

@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404, render
from django.http import Http404
from django.utils.http import urlencode
from django.contrib.auth.decorators import permission_required
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from wagtail.wagtailadmin.modal_workflow import render_modal_workflow
@ -20,7 +19,6 @@ def get_querystring(request):
})
@permission_required('wagtailadmin.access_admin')
def browse(request, parent_page_id=None):
page_type = request.GET.get('page_type') or 'wagtailcore.page'
content_type_app_name, content_type_model_name = page_type.split('.')
@ -89,7 +87,6 @@ def browse(request, parent_page_id=None):
})
@permission_required('wagtailadmin.access_admin')
def external_link(request):
prompt_for_link_text = bool(request.GET.get('prompt_for_link_text'))
@ -123,7 +120,6 @@ def external_link(request):
)
@permission_required('wagtailadmin.access_admin')
def email_link(request):
prompt_for_link_text = bool(request.GET.get('prompt_for_link_text'))

View file

@ -1,5 +1,4 @@
from django.shortcuts import render
from django.contrib.auth.decorators import permission_required
from django.conf import settings
from django.template import RequestContext
from django.template.loader import render_to_string
@ -66,7 +65,6 @@ class RecentEditsPanel(object):
}, RequestContext(self.request))
@permission_required('wagtailadmin.access_admin')
def home(request):
panels = [

View file

@ -1,12 +1,11 @@
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404
from wagtail.wagtailcore.models import Page, PageViewRestriction
from wagtail.wagtailadmin.forms import PageViewRestrictionForm
from wagtail.wagtailadmin.modal_workflow import render_modal_workflow
@permission_required('wagtailadmin.access_admin')
def set_privacy(request, page_id):
page = get_object_or_404(Page, id=page_id)
page_perms = page.permissions_for_user(request.user)

View file

@ -4,7 +4,6 @@ from django.http import Http404, HttpResponse
from django.shortcuts import render, redirect, get_object_or_404
from django.core.exceptions import ValidationError, PermissionDenied
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.decorators import permission_required
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.core.urlresolvers import reverse
from django.utils import timezone
@ -20,17 +19,17 @@ from wagtail.wagtailadmin import tasks, signals
from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.models import Page, PageRevision, get_navigation_menu_items
from wagtail.wagtailcore.validators import validate_not_whitespace
from wagtail.wagtailadmin import messages
@permission_required('wagtailadmin.access_admin')
def explorer_nav(request):
return render(request, 'wagtailadmin/shared/explorer_nav.html', {
'nodes': get_navigation_menu_items(),
})
@permission_required('wagtailadmin.access_admin')
def index(request, parent_page_id=None):
if parent_page_id:
parent_page = get_object_or_404(Page, id=parent_page_id)
@ -67,7 +66,6 @@ def index(request, parent_page_id=None):
})
@permission_required('wagtailadmin.access_admin')
def add_subpage(request, parent_page_id):
parent_page = get_object_or_404(Page, id=parent_page_id).specific
if not parent_page.permissions_for_user(request.user).can_add_subpage():
@ -89,7 +87,6 @@ def add_subpage(request, parent_page_id):
})
@permission_required('wagtailadmin.access_admin')
def content_type_use(request, content_type_app_name, content_type_model_name):
try:
content_type = ContentType.objects.get_by_natural_key(content_type_app_name, content_type_model_name)
@ -123,7 +120,6 @@ def content_type_use(request, content_type_app_name, content_type_model_name):
})
@permission_required('wagtailadmin.access_admin')
def create(request, content_type_app_name, content_type_model_name, parent_page_id):
parent_page = get_object_or_404(Page, id=parent_page_id).specific
parent_page_perms = parent_page.permissions_for_user(request.user)
@ -161,6 +157,19 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_
return slug
form.fields['slug'].clean = clean_slug
# Validate title and seo_title are not entirely whitespace
def clean_title(title):
validate_not_whitespace(title)
return title
form.fields['title'].clean = clean_title
def clean_seo_title(seo_title):
if not seo_title:
return ''
validate_not_whitespace(seo_title)
return seo_title
form.fields['seo_title'].clean = clean_seo_title
# Stick another validator into the form to check that the scheduled publishing settings are set correctly
def clean():
cleaned_data = form_class.clean(form)
@ -249,7 +258,6 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_
})
@permission_required('wagtailadmin.access_admin')
def edit(request, page_id):
latest_revision = get_object_or_404(Page, id=page_id).get_latest_revision()
page = get_object_or_404(Page, id=page_id).get_latest_revision_as_page()
@ -277,6 +285,20 @@ def edit(request, page_id):
return slug
form.fields['slug'].clean = clean_slug
# Validate title and seo_title are not entirely whitespace
def clean_title(title):
validate_not_whitespace(title)
return title
form.fields['title'].clean = clean_title
def clean_seo_title(seo_title):
if not seo_title:
return ''
validate_not_whitespace(seo_title)
return seo_title
form.fields['seo_title'].clean = clean_seo_title
# Stick another validator into the form to check that the scheduled publishing settings are set correctly
def clean():
cleaned_data = form_class.clean(form)
@ -383,7 +405,6 @@ def edit(request, page_id):
})
@permission_required('wagtailadmin.access_admin')
def delete(request, page_id):
page = get_object_or_404(Page, id=page_id).specific
if not page.permissions_for_user(request.user).can_delete():
@ -408,13 +429,11 @@ def delete(request, page_id):
})
@permission_required('wagtailadmin.access_admin')
def view_draft(request, page_id):
page = get_object_or_404(Page, id=page_id).get_latest_revision_as_page()
return page.serve_preview(page.dummy_request(), page.default_preview_mode)
@permission_required('wagtailadmin.access_admin')
def preview_on_edit(request, page_id):
# Receive the form submission that would typically be posted to the 'edit' view. If submission is valid,
# return the rendered page; if not, re-render the edit form
@ -444,7 +463,6 @@ def preview_on_edit(request, page_id):
return response
@permission_required('wagtailadmin.access_admin')
def preview_on_create(request, content_type_app_name, content_type_model_name, parent_page_id):
# Receive the form submission that would typically be posted to the 'create' view. If submission is valid,
# return the rendered page; if not, re-render the edit form
@ -520,7 +538,7 @@ def preview_loading(request):
"""
return HttpResponse("<html><head><title></title></head><body></body></html>")
@permission_required('wagtailadmin.access_admin')
def unpublish(request, page_id):
page = get_object_or_404(Page, id=page_id).specific
if not page.permissions_for_user(request.user).can_unpublish():
@ -538,7 +556,6 @@ def unpublish(request, page_id):
})
@permission_required('wagtailadmin.access_admin')
def move_choose_destination(request, page_to_move_id, viewed_page_id=None):
page_to_move = get_object_or_404(Page, id=page_to_move_id)
page_perms = page_to_move.permissions_for_user(request.user)
@ -568,7 +585,6 @@ def move_choose_destination(request, page_to_move_id, viewed_page_id=None):
})
@permission_required('wagtailadmin.access_admin')
def move_confirm(request, page_to_move_id, destination_id):
page_to_move = get_object_or_404(Page, id=page_to_move_id).specific
destination = get_object_or_404(Page, id=destination_id)
@ -590,7 +606,6 @@ def move_confirm(request, page_to_move_id, destination_id):
})
@permission_required('wagtailadmin.access_admin')
def set_page_position(request, page_to_move_id):
page_to_move = get_object_or_404(Page, id=page_to_move_id)
parent_page = page_to_move.get_parent()
@ -630,7 +645,6 @@ def set_page_position(request, page_to_move_id):
return HttpResponse('')
@permission_required('wagtailadmin.access_admin')
def copy(request, page_id):
page = Page.objects.get(id=page_id)
@ -698,12 +712,11 @@ def get_page_edit_handler(page_class):
ObjectList(page_class.content_panels, heading='Content'),
ObjectList(page_class.promote_panels, heading='Promote'),
ObjectList(page_class.settings_panels, heading='Settings', classname="settings")
])
]).bind_to_model(page_class)
return PAGE_EDIT_HANDLERS[page_class]
@permission_required('wagtailadmin.access_admin')
@vary_on_headers('X-Requested-With')
def search(request):
pages = []
@ -745,7 +758,6 @@ def search(request):
})
@permission_required('wagtailadmin.access_admin')
def approve_moderation(request, revision_id):
revision = get_object_or_404(PageRevision, id=revision_id)
if not revision.page.permissions_for_user(request.user).can_publish():
@ -763,7 +775,6 @@ def approve_moderation(request, revision_id):
return redirect('wagtailadmin_home')
@permission_required('wagtailadmin.access_admin')
def reject_moderation(request, revision_id):
revision = get_object_or_404(PageRevision, id=revision_id)
if not revision.page.permissions_for_user(request.user).can_publish():
@ -781,7 +792,6 @@ def reject_moderation(request, revision_id):
return redirect('wagtailadmin_home')
@permission_required('wagtailadmin.access_admin')
@require_GET
def preview_for_moderation(request, revision_id):
revision = get_object_or_404(PageRevision, id=revision_id)
@ -801,7 +811,6 @@ def preview_for_moderation(request, revision_id):
return page.serve_preview(request, page.default_preview_mode)
@permission_required('wagtailadmin.access_admin')
@require_POST
def lock(request, page_id):
# Get the page
@ -826,7 +835,6 @@ def lock(request, page_id):
return redirect('wagtailadmin_explore', page.get_parent().id)
@permission_required('wagtailadmin.access_admin')
@require_POST
def unlock(request, page_id):
# Get the page

View file

@ -3,10 +3,8 @@ import json
from taggit.models import Tag
from django.http import HttpResponse
from django.contrib.auth.decorators import permission_required
@permission_required('wagtailadmin.access_admin')
def autocomplete(request):
term = request.GET.get('term', None)
if term:

View file

@ -859,10 +859,10 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
# Apply middleware to the request - see http://www.mellowmorning.com/2011/04/18/mock-django-request-for-testing/
handler = BaseHandler()
handler.load_middleware()
# call each middleware in turn and throw away any responses that they might return
for middleware_method in handler._request_middleware:
if middleware_method(request):
raise Exception("Couldn't create request mock object - "
"request middleware returned a response")
middleware_method(request)
return request
DEFAULT_PREVIEW_MODES = [('', 'Default')]

View file

@ -0,0 +1,14 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from wagtail.wagtailcore.validators import validate_not_whitespace
class TestValidators(TestCase):
def test_not_whitespace(self):
validate_not_whitespace('bar')
for test_value in (' ', '\t', '\r', '\n', '\r\n'):
with self.assertRaises(ValidationError):
validate_not_whitespace(test_value)

View file

@ -0,0 +1,15 @@
import re
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
WHITESPACE_RE = re.compile(r'^\s+$')
def validate_not_whitespace(value):
"""
Validate that a value isn't all whitespace, for example in title and
seo_title
"""
if value and WHITESPACE_RE.match(value):
raise ValidationError(_('Value cannot be entirely whitespace characters'))

View file

@ -13,7 +13,12 @@ class BaseDocumentChooserPanel(BaseChooserPanel):
return {cls.field_name: AdminDocumentChooser}
def DocumentChooserPanel(field_name):
return type(str('_DocumentChooserPanel'), (BaseDocumentChooserPanel,), {
'field_name': field_name,
})
class DocumentChooserPanel(object):
def __init__(self, field_name):
self.field_name = field_name
def bind_to_model(self, model):
return type(str('_DocumentChooserPanel'), (BaseDocumentChooserPanel,), {
'model': model,
'field_name': self.field_name,
})

View file

@ -12,7 +12,6 @@ from wagtail.wagtaildocs.models import Document
from wagtail.wagtaildocs.forms import DocumentForm
@permission_required('wagtailadmin.access_admin')
def chooser(request):
if request.user.has_perm('wagtaildocs.add_document'):
uploadform = DocumentForm()
@ -77,7 +76,6 @@ def chooser(request):
})
@permission_required('wagtailadmin.access_admin')
def document_chosen(request, document_id):
document = get_object_or_404(Document, id=document_id)

View file

@ -103,7 +103,6 @@ def add(request):
})
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def edit(request, document_id):
doc = get_object_or_404(Document, id=document_id)
@ -140,7 +139,6 @@ def edit(request, document_id):
})
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def delete(request, document_id):
doc = get_object_or_404(Document, id=document_id)
@ -157,7 +155,6 @@ def delete(request, document_id):
})
@permission_required('wagtailadmin.access_admin')
def usage(request, document_id):
doc = get_object_or_404(Document, id=document_id)

View file

@ -11,14 +11,12 @@ from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.contrib.auth.decorators import permission_required
from wagtail.wagtailcore.models import Page
from wagtail.wagtailforms.models import FormSubmission, get_forms_for_user
from wagtail.wagtailforms.forms import SelectDateForm
@permission_required('wagtailadmin.access_admin')
def index(request):
p = request.GET.get("p", 1)
@ -38,7 +36,6 @@ def index(request):
})
@permission_required('wagtailadmin.access_admin')
def list_submissions(request, page_id):
form_page = get_object_or_404(Page, id=page_id).specific

View file

@ -1,51 +0,0 @@
# Backend loading
# Based on the Django cache framework and wagtailsearch
# https://github.com/django/django/blob/5d263dee304fdaf95e18d2f0619d6925984a7f02/django/core/cache/__init__.py
from django.utils.module_loading import import_string
from django.core.exceptions import ImproperlyConfigured
from django.conf import settings
class InvalidImageBackendError(ImproperlyConfigured):
pass
def get_image_backend(backend='default', **kwargs):
# Get configuration
default_conf = {
'default': {
'BACKEND': 'wagtail.wagtailimages.backends.pillow.PillowBackend',
},
}
WAGTAILIMAGES_BACKENDS = getattr(
settings, 'WAGTAILIMAGES_BACKENDS', default_conf)
# Try to find the backend
try:
# Try to get the WAGTAILIMAGES_BACKENDS entry for the given backend name first
conf = WAGTAILIMAGES_BACKENDS[backend]
except KeyError:
try:
# Trying to import the given backend, in case it's a dotted path
import_string(backend)
except ImportError as e:
raise InvalidImageBackendError("Could not find backend '%s': %s" % (
backend, e))
params = kwargs
else:
# Backend is a conf entry
params = conf.copy()
params.update(kwargs)
backend = params.pop('BACKEND')
# Try to import the backend
try:
backend_cls = import_string(backend)
except ImportError as e:
raise InvalidImageBackendError("Could not find backend '%s': %s" % (
backend, e))
# Create backend
return backend_cls(params)

View file

@ -1,249 +0,0 @@
from __future__ import division
from django.conf import settings
from wagtail.wagtailimages.rect import Rect
class BaseImageBackend(object):
def __init__(self, params):
self.quality = getattr(settings, 'IMAGE_COMPRESSION_QUALITY', 85)
def open_image(self, input_file):
"""
Open an image and return the backend specific image object to pass
to other methods. The object return has to have a size attribute
which is a tuple with the width and height of the image and a format
attribute with the format of the image.
"""
raise NotImplementedError('subclasses of BaseImageBackend must provide an open_image() method')
def save_image(self, image, output):
"""
Save the image to the output
"""
raise NotImplementedError('subclasses of BaseImageBackend must provide a save_image() method')
def resize(self, image, size):
"""
resize image to the requested size, using highest quality settings
(antialiasing enabled, converting to true colour if required)
"""
raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method')
def image_data_as_rgb(self, image):
raise NotImplementedError('subclasses of BaseImageBackend must provide an image_data_as_rgb() method')
def crop(self, image, crop_box):
raise NotImplementedError('subclasses of BaseImageBackend must provide a crop() method')
def resize_to_max(self, image, size, focal_point=None):
"""
Resize image down to fit within the given dimensions, preserving aspect ratio.
Will leave image unchanged if it's already within those dimensions.
"""
(original_width, original_height) = image.size
(target_width, target_height) = size
if original_width <= target_width and original_height <= target_height:
return image
# scale factor if we were to downsize the image to fit the target width
horz_scale = target_width / original_width
# scale factor if we were to downsize the image to fit the target height
vert_scale = target_height / original_height
# choose whichever of these gives a smaller image
if horz_scale < vert_scale:
final_size = (target_width, int(original_height * horz_scale))
else:
final_size = (int(original_width * vert_scale), target_height)
return self.resize(image, final_size)
def resize_to_min(self, image, size, focal_point=None):
"""
Resize image down to cover the given dimensions, preserving aspect ratio.
Will leave image unchanged if width or height is already within those limits.
"""
(original_width, original_height) = image.size
(target_width, target_height) = size
if original_width <= target_width or original_height <= target_height:
return image
# scale factor if we were to downsize the image to fit the target width
horz_scale = target_width / original_width
# scale factor if we were to downsize the image to fit the target height
vert_scale = target_height / original_height
# choose whichever of these gives a larger image
if horz_scale > vert_scale:
final_size = (target_width, int(original_height * horz_scale))
else:
final_size = (int(original_width * vert_scale), target_height)
return self.resize(image, final_size)
def resize_to_width(self, image, target_width, focal_point=None):
"""
Resize image down to the given width, preserving aspect ratio.
Will leave image unchanged if it's already within that width.
"""
(original_width, original_height) = image.size
if original_width <= target_width:
return image
scale = target_width / original_width
final_size = (target_width, int(original_height * scale))
return self.resize(image, final_size)
def resize_to_height(self, image, target_height, focal_point=None):
"""
Resize image down to the given height, preserving aspect ratio.
Will leave image unchanged if it's already within that height.
"""
(original_width, original_height) = image.size
if original_height <= target_height:
return image
scale = target_height / original_height
final_size = (int(original_width * scale), target_height)
return self.resize(image, final_size)
def resize_to_fill(self, image, arg, focal_point=None):
"""
Resize down and crop image to fill the given dimensions. Most suitable for thumbnails.
(The final image will match the requested size, unless one or the other dimension is
already smaller than the target size)
"""
size = arg[:2]
# Get crop closeness if it's set
if len(arg) > 2 and arg[2] is not None:
crop_closeness = arg[2] / 100
# Clamp it
if crop_closeness > 1:
crop_closeness = 1
else:
crop_closeness = 0
# Get image width and height
(im_width, im_height) = image.size
# Get filter width and height
fl_width = size[0]
fl_height = size[1]
# Get crop aspect ratio
crop_aspect_ratio = fl_width / fl_height
# Get crop max
crop_max_scale = min(im_width, im_height * crop_aspect_ratio)
crop_max_width = crop_max_scale
crop_max_height = crop_max_scale / crop_aspect_ratio
# Initialise crop width and height to max
crop_width = crop_max_width
crop_height = crop_max_height
# Use crop closeness to zoom in
if focal_point is not None:
fp_width = focal_point.width
fp_height = focal_point.height
# Get crop min
crop_min_scale = max(fp_width, fp_height * crop_aspect_ratio)
crop_min_width = crop_min_scale
crop_min_height = crop_min_scale / crop_aspect_ratio
# Sometimes, the focal point may be bigger than the image...
if not crop_min_scale >= crop_max_scale:
# Calculate max crop closeness to prevent upscaling
max_crop_closeness = max(
1 - (fl_width - crop_min_width) / (crop_max_width - crop_min_width),
1 - (fl_height - crop_min_height) / (crop_max_height - crop_min_height)
)
# Apply max crop closeness
crop_closeness = min(crop_closeness, max_crop_closeness)
if 1 >= crop_closeness >= 0:
# Get crop width and height
crop_width = crop_max_width + (crop_min_width - crop_max_width) * crop_closeness
crop_height = crop_max_height + (crop_min_height - crop_max_height) * crop_closeness
# Find focal point UV
if focal_point is not None:
fp_x, fp_y = focal_point.centroid
else:
# Fall back to positioning in the centre
fp_x = im_width / 2
fp_y = im_height / 2
fp_u = fp_x / im_width
fp_v = fp_y / im_height
# Position crop box based on focal point UV
crop_x = fp_x - (fp_u - 0.5) * crop_width
crop_y = fp_y - (fp_v - 0.5) * crop_height
# Convert crop box into rect
left = crop_x - crop_width / 2
top = crop_y - crop_height / 2
right = crop_x + crop_width / 2
bottom = crop_y + crop_height / 2
# Make sure the entire focal point is in the crop box
if focal_point is not None:
focal_point_left = focal_point.left
focal_point_top = focal_point.top
focal_point_right = focal_point.right
focal_point_bottom = focal_point.bottom
if left > focal_point_left:
right -= left - focal_point_left
left = focal_point_left
if top > focal_point_top:
bottom -= top - focal_point_top
top = focal_point_top
if right < focal_point_right:
left += focal_point_right - right
right = focal_point_right
if bottom < focal_point_bottom:
top += focal_point_bottom - bottom
bottom = focal_point_bottom
# Don't allow the crop box to go over the image boundary
if left < 0:
right -= left
left = 0
if top < 0:
bottom -= top
top = 0
if right > im_width:
left -= right - im_width
right = im_width
if bottom > im_height:
top -= bottom - im_height
bottom = im_height
# Crop!
return self.resize_to_min(self.crop(image, Rect(left, top, right, bottom)), size)
def no_operation(self, image, param, focal_point=None):
"""Return the image unchanged"""
return image

View file

@ -1,35 +0,0 @@
from __future__ import absolute_import
import PIL.Image
from wagtail.wagtailimages.backends.base import BaseImageBackend
class PillowBackend(BaseImageBackend):
def __init__(self, params):
super(PillowBackend, self).__init__(params)
def open_image(self, input_file):
image = PIL.Image.open(input_file)
return image
def save_image(self, image, output, format):
image.save(output, format, quality=self.quality)
def _to_rgb(self, image):
if image.mode not in ['RGB', 'RGBA']:
if 'transparency' in image.info and isinstance(image.info['transparency'], bytes):
image = image.convert('RGBA')
else:
image = image.convert('RGB')
return image
def resize(self, image, size):
return self._to_rgb(image).resize(size, PIL.Image.ANTIALIAS)
def crop(self, image, rect):
return image.crop(rect)
def image_data_as_rgb(self, image):
image = self._to_rgb(image)
return image.mode, image.tostring()

View file

@ -1,41 +0,0 @@
from __future__ import absolute_import
from wand.image import Image
from wand.api import library
from wagtail.wagtailimages.backends.base import BaseImageBackend
class WandBackend(BaseImageBackend):
def __init__(self, params):
super(WandBackend, self).__init__(params)
def open_image(self, input_file):
image = Image(file=input_file)
image.wand = library.MagickCoalesceImages(image.wand)
return image
def save_image(self, image, output, format):
image.format = format
image.compression_quality = self.quality
image.save(file=output)
def resize(self, image, size):
new_image = image.clone()
new_image.resize(size[0], size[1])
return new_image
def crop(self, image, rect):
new_image = image.clone()
new_image.crop(
left=rect[0], top=rect[1], right=rect[2], bottom=rect[3]
)
return new_image
def image_data_as_rgb(self, image):
# Only return image data if this image is not animated
if image.animation:
return
return 'RGB', image.make_blob('RGB')

View file

@ -13,7 +13,12 @@ class BaseImageChooserPanel(BaseChooserPanel):
return {cls.field_name: AdminImageChooser}
def ImageChooserPanel(field_name):
return type(str('_ImageChooserPanel'), (BaseImageChooserPanel,), {
'field_name': field_name,
})
class ImageChooserPanel(object):
def __init__(self, field_name):
self.field_name = field_name
def bind_to_model(self, model):
return type(str('_ImageChooserPanel'), (BaseImageChooserPanel,), {
'model': model,
'field_name': self.field_name,
})

View file

@ -0,0 +1,2 @@
class InvalidFilterSpecError(ValueError):
pass

View file

@ -1,78 +0,0 @@
import os
from django.conf import settings
# only try to import OpenCV if WAGTAILIMAGES_FEATURE_DETECTION_ENABLED is True -
# avoids spurious "libdc1394 error: Failed to initialize libdc1394" errors on sites that
# don't even use OpenCV
if getattr(settings, 'WAGTAILIMAGES_FEATURE_DETECTION_ENABLED', False):
try:
import cv
opencv_available = True
except ImportError:
try:
import cv2.cv as cv
opencv_available = True
except ImportError:
opencv_available = False
else:
opencv_available = False
from wagtail.wagtailimages.rect import Rect
class FeatureDetector(object):
def __init__(self, image_size, image_mode, image_data):
self.image_size = image_size
self.image_mode = image_mode
self.image_data = image_data
def opencv_grey_image(self):
image = cv.CreateImageHeader(self.image_size, cv.IPL_DEPTH_8U, 3)
cv.SetData(image, self.image_data)
gray_image = cv.CreateImage(self.image_size, 8, 1)
convert_mode = getattr(cv, 'CV_%s2GRAY' % self.image_mode)
cv.CvtColor(image, gray_image, convert_mode)
return gray_image
def detect_features(self):
if opencv_available:
image = self.opencv_grey_image()
rows = self.image_size[0]
cols = self.image_size[1]
eig_image = cv.CreateMat(rows, cols, cv.CV_32FC1)
temp_image = cv.CreateMat(rows, cols, cv.CV_32FC1)
points = cv.GoodFeaturesToTrack(image, eig_image, temp_image, 20, 0.04, 1.0, useHarris=False)
if points:
return points
return []
def detect_faces(self):
if opencv_available:
cascade_filename = os.path.join(os.path.dirname(__file__), 'face_detection', 'haarcascade_frontalface_alt2.xml')
cascade = cv.Load(cascade_filename)
image = self.opencv_grey_image()
cv.EqualizeHist(image, image)
min_size = (40, 40)
haar_scale = 1.1
min_neighbors = 3
haar_flags = 0
faces = cv.HaarDetectObjects(
image, cascade, cv.CreateMemStorage(0),
haar_scale, min_neighbors, haar_flags, min_size
)
if faces:
return [Rect(face[0][0], face[0][1], face[0][0] + face[0][2], face[0][1] + face[0][3]) for face in faces]
return []

View file

@ -0,0 +1,265 @@
from __future__ import division
import inspect
from wagtail.wagtailimages.exceptions import InvalidFilterSpecError
class Operation(object):
def __init__(self, method, *args):
self.method = method
self.args = args
# Check arguments
try:
inspect.getcallargs(self.construct, *args)
except TypeError as e:
raise InvalidFilterSpecError(e)
# Call construct
try:
self.construct(*args)
except ValueError as e:
raise InvalidFilterSpecError(e)
def construct(self, *args):
raise NotImplementedError
def run(self, willow, image):
raise NotImplementedError
class DoNothingOperation(Operation):
def construct(self):
pass
def run(self, willow, image):
pass
class FillOperation(Operation):
def construct(self, size, *extra):
# Get width and height
width_str, height_str = size.split('x')
self.width = int(width_str)
self.height = int(height_str)
# Crop closeness
self.crop_closeness = 0
for extra_part in extra:
if extra_part.startswith('c'):
self.crop_closeness = int(extra_part[1:])
else:
raise ValueError("Unrecognised filter spec part: %s" % extra_part)
# Divide it by 100 (as it's a percentage)
self.crop_closeness /= 100
# Clamp it
if self.crop_closeness > 1:
self.crop_closeness = 1
def run(self, willow, image):
image_width, image_height = willow.get_size()
focal_point = image.get_focal_point()
# Get crop aspect ratio
crop_aspect_ratio = self.width / self.height
# Get crop max
crop_max_scale = min(image_width, image_height * crop_aspect_ratio)
crop_max_width = crop_max_scale
crop_max_height = crop_max_scale / crop_aspect_ratio
# Initialise crop width and height to max
crop_width = crop_max_width
crop_height = crop_max_height
# Use crop closeness to zoom in
if focal_point is not None:
# Get crop min
crop_min_scale = max(focal_point.width, focal_point.height * crop_aspect_ratio)
crop_min_width = crop_min_scale
crop_min_height = crop_min_scale / crop_aspect_ratio
# Sometimes, the focal point may be bigger than the image...
if not crop_min_scale >= crop_max_scale:
# Calculate max crop closeness to prevent upscaling
max_crop_closeness = max(
1 - (self.width - crop_min_width) / (crop_max_width - crop_min_width),
1 - (self.height - crop_min_height) / (crop_max_height - crop_min_height)
)
# Apply max crop closeness
crop_closeness = min(self.crop_closeness, max_crop_closeness)
if 1 >= crop_closeness >= 0:
# Get crop width and height
crop_width = crop_max_width + (crop_min_width - crop_max_width) * crop_closeness
crop_height = crop_max_height + (crop_min_height - crop_max_height) * crop_closeness
# Find focal point UV
if focal_point is not None:
fp_x, fp_y = focal_point.centroid
else:
# Fall back to positioning in the centre
fp_x = image_width / 2
fp_y = image_height / 2
fp_u = fp_x / image_width
fp_v = fp_y / image_height
# Position crop box based on focal point UV
crop_x = fp_x - (fp_u - 0.5) * crop_width
crop_y = fp_y - (fp_v - 0.5) * crop_height
# Convert crop box into rect
left = crop_x - crop_width / 2
top = crop_y - crop_height / 2
right = crop_x + crop_width / 2
bottom = crop_y + crop_height / 2
# Make sure the entire focal point is in the crop box
if focal_point is not None:
if left > focal_point.left:
right -= left - focal_point.left
left = focal_point.left
if top > focal_point.top:
bottom -= top - focal_point.top
top = focal_point.top
if right < focal_point.right:
left += focal_point.right - right
right = focal_point.right
if bottom < focal_point.bottom:
top += focal_point.bottom - bottom
bottom = focal_point.bottom
# Don't allow the crop box to go over the image boundary
if left < 0:
right -= left
left = 0
if top < 0:
bottom -= top
top = 0
if right > image_width:
left -= right - image_width
right = image_width
if bottom > image_height:
top -= bottom - image_height
bottom = image_height
# Crop!
willow.crop(int(left), int(top), int(right), int(bottom))
# Resize the final image
aftercrop_width, aftercrop_height = willow.get_size()
horz_scale = self.width / aftercrop_width
vert_scale = self.height / aftercrop_height
if aftercrop_width <= self.width or aftercrop_height <= self.height:
return
if horz_scale > vert_scale:
width = self.width
height = int(aftercrop_height * horz_scale)
else:
width = int(aftercrop_width * vert_scale)
height = self.height
willow.resize(width, height)
def get_vary(self, image):
focal_point = image.get_focal_point()
if focal_point is not None:
focal_point_key = "%(x)d-%(y)d-%(width)dx%(height)d" % {
'x': int(focal_point.centroid_x),
'y': int(focal_point.centroid_y),
'width': int(focal_point.width),
'height': int(focal_point.height),
}
else:
focal_point_key = ''
return [focal_point_key]
class MinMaxOperation(Operation):
def construct(self, size):
# Get width and height
width_str, height_str = size.split('x')
self.width = int(width_str)
self.height = int(height_str)
def run(self, willow, image):
image_width, image_height = willow.get_size()
horz_scale = self.width / image_width
vert_scale = self.height / image_height
if self.method == 'min':
if image_width <= self.width or image_height <= self.height:
return
if horz_scale > vert_scale:
width = self.width
height = int(image_height * horz_scale)
else:
width = int(image_width * vert_scale)
height = self.height
elif self.method == 'max':
if image_width <= self.width and image_height <= self.height:
return
if horz_scale < vert_scale:
width = self.width
height = int(image_height * horz_scale)
else:
width = int(image_width * vert_scale)
height = self.height
else:
# Unknown method
return
willow.resize(width, height)
class WidthHeightOperation(Operation):
def construct(self, size):
self.size = int(size)
def run(self, willow, image):
image_width, image_height = willow.get_size()
if self.method == 'width':
if image_width <= self.size:
return
scale = self.size / image_width
width = self.size
height = int(image_height * scale)
elif self.method == 'height':
if image_height <= self.size:
return
scale = self.size / image_height
width = int(image_width * scale)
height = self.size
else:
# Unknown method
return
willow.resize(width, height)

View file

@ -1,4 +1,5 @@
import os.path
import hashlib
import re
from six import BytesIO, text_type
@ -21,11 +22,11 @@ from django.core.urlresolvers import reverse
from unidecode import unidecode
from wagtail.wagtailcore import hooks
from wagtail.wagtailadmin.taggable import TagSearchable
from wagtail.wagtailimages.backends import get_image_backend
from wagtail.wagtailsearch import index
from wagtail.wagtailimages.feature_detection import FeatureDetector, opencv_available
from wagtail.wagtailimages.rect import Rect
from wagtail.wagtailimages.exceptions import InvalidFilterSpecError
from wagtail.wagtailadmin.utils import get_object_usage
@ -124,36 +125,19 @@ class AbstractImage(models.Model, TagSearchable):
self.focal_point_width = None
self.focal_point_height = None
def get_suggested_focal_point(self, backend_name='default'):
backend = get_image_backend(backend_name)
image_file = self.file.file
def get_suggested_focal_point(self):
willow = self.get_willow_image()
# Make sure image is open and seeked to the beginning
image_file.open('rb')
image_file.seek(0)
# Load the image
image = backend.open_image(self.file.file)
image_data = backend.image_data_as_rgb(image)
# Make sure we have image data
# If the image is animated, image_data_as_rgb will return None
if image_data is None:
return
# Use feature detection to find a focal point
feature_detector = FeatureDetector(image.size, image_data[0], image_data[1])
faces = feature_detector.detect_faces()
faces = willow.detect_faces()
if faces:
# Create a bounding box around all faces
left = min(face.left for face in faces)
top = min(face.top for face in faces)
right = max(face.right for face in faces)
bottom = max(face.bottom for face in faces)
left = min(face[0] for face in faces)
top = min(face[1] for face in faces)
right = max(face[2] for face in faces)
bottom = max(face[3] for face in faces)
focal_point = Rect(left, top, right, bottom)
else:
features = feature_detector.detect_features()
features = willow.detect_features()
if features:
# Create a bounding box around all features
left = min(feature[0] for feature in features)
@ -177,62 +161,35 @@ class AbstractImage(models.Model, TagSearchable):
return Rect.from_point(x, y, width, height)
def get_rendition(self, filter):
if not hasattr(filter, 'process_image'):
if not hasattr(filter, 'run'):
# assume we've been passed a filter spec string, rather than a Filter object
# TODO: keep an in-memory cache of filters, to avoid a db lookup
filter, created = Filter.objects.get_or_create(spec=filter)
vary_key = filter.get_vary_key(self)
try:
if self.has_focal_point():
rendition = self.renditions.get(
filter=filter,
focal_point_key=self.get_focal_point().get_key(),
)
else:
rendition = self.renditions.get(
filter=filter,
focal_point_key='',
)
rendition = self.renditions.get(
filter=filter,
focal_point_key=vary_key,
)
except ObjectDoesNotExist:
file_field = self.file
# Generate the rendition image
generated_image = filter.run(self, BytesIO())
# If we have a backend attribute then pass it to process
# image - else pass 'default'
backend_name = getattr(self, 'backend', 'default')
# Generate filename
input_filename = os.path.basename(self.file.name)
input_filename_without_extension, input_extension = os.path.splitext(input_filename)
try:
image_file = file_field.file # triggers a call to self.storage.open, so IOErrors from missing files will be raised at this point
except IOError as e:
# re-throw this as a SourceImageIOError so that calling code can distinguish
# these from IOErrors elsewhere in the process
raise SourceImageIOError(text_type(e))
output_extension = '.'.join([vary_key, filter.spec]) + input_extension
output_filename_without_extension = input_filename_without_extension[:(59-len(output_extension))] # Truncate filename to prevent it going over 60 chars
output_filename = output_filename_without_extension + '.' + output_extension
generated_image = filter.process_image(image_file, backend_name=backend_name, focal_point=self.get_focal_point())
# generate new filename derived from old one, inserting the filter spec and focal point key before the extension
if self.has_focal_point():
focal_point_key = "focus-" + self.get_focal_point().get_key()
else:
focal_point_key = "focus-none"
input_filename_parts = os.path.basename(file_field.file.name).split('.')
filename_without_extension = '.'.join(input_filename_parts[:-1])
extension = '.'.join([focal_point_key, filter.spec] + input_filename_parts[-1:])
filename_without_extension = filename_without_extension[:(59-len(extension))] # Truncate filename to prevent it going over 60 chars
output_filename = filename_without_extension + '.' + extension
generated_image_file = File(generated_image, name=output_filename)
if self.has_focal_point():
rendition, created = self.renditions.get_or_create(
filter=filter,
focal_point_key=self.get_focal_point().get_key(),
defaults={'file': generated_image_file}
)
else:
rendition, created = self.renditions.get_or_create(
filter=filter,
defaults={'file': generated_image_file}
)
rendition, created = self.renditions.get_or_create(
filter=filter,
focal_point_key=vary_key,
defaults={'file': File(generated_image, name=output_filename)}
)
return rendition
@ -275,9 +232,6 @@ class Image(AbstractImage):
@receiver(pre_save, sender=Image)
def image_feature_detection(sender, instance, **kwargs):
if getattr(settings, 'WAGTAILIMAGES_FEATURE_DETECTION_ENABLED', False):
if not opencv_available:
raise ImproperlyConfigured("pyOpenCV could not be found.")
# Make sure the image doesn't already have a focal point
if not instance.has_focal_point():
# Set the focal point
@ -316,91 +270,62 @@ class Filter(models.Model):
"""
spec = models.CharField(max_length=255, db_index=True, unique=True)
OPERATION_NAMES = {
'max': 'resize_to_max',
'min': 'resize_to_min',
'width': 'resize_to_width',
'height': 'resize_to_height',
'fill': 'resize_to_fill',
'original': 'no_operation',
}
class InvalidFilterSpecError(ValueError):
pass
def _parse_spec_string(self):
# parse the spec string and return the method name and method arg.
# There are various possible formats to match against:
# 'original'
# 'width-200'
# 'max-320x200'
# 'fill-200x200-c50'
if self.spec == 'original':
return Filter.OPERATION_NAMES['original'], None
match = re.match(r'(width|height)-(\d+)$', self.spec)
if match:
return Filter.OPERATION_NAMES[match.group(1)], int(match.group(2))
match = re.match(r'(fill)-(\d+)x(\d+)-c(\d+)$', self.spec)
if match:
width = int(match.group(2))
height = int(match.group(3))
crop_closeness = int(match.group(4))
return Filter.OPERATION_NAMES[match.group(1)], (width, height, crop_closeness)
match = re.match(r'(max|min|fill)-(\d+)x(\d+)$', self.spec)
if match:
width = int(match.group(2))
height = int(match.group(3))
return Filter.OPERATION_NAMES[match.group(1)], (width, height)
# Spec is not one of our recognised patterns
raise Filter.InvalidFilterSpecError("Invalid image filter spec: %r" % self.spec)
@cached_property
def _method(self):
return self._parse_spec_string()
def operations(self):
# Search for operations
self._search_for_operations()
def is_valid(self):
try:
self._parse_spec_string()
return True
except Filter.InvalidFilterSpecError:
return False
# Build list of operation objects
operations = []
for op_spec in self.spec.split():
op_spec_parts = op_spec.split('-')
def process_image(self, input_file, output_file=None, focal_point=None, backend_name='default'):
"""
Run this filter on the given image file then write the result into output_file and return it
If output_file is not given, a new BytesIO will be used instead
"""
# Get backend
backend = get_image_backend(backend_name)
if op_spec_parts[0] not in self._registered_operations:
raise InvalidFilterSpecError("Unrecognised operation: %s" % op_spec_parts[0])
# Parse spec string
method_name, method_arg = self._method
op_class = self._registered_operations[op_spec_parts[0]]
operations.append(op_class(*op_spec_parts))
# Open image
input_file.open('rb')
image = backend.open_image(input_file)
file_format = image.format
return operations
# Process image
method = getattr(backend, method_name)
image = method(image, method_arg, focal_point=focal_point)
def run(self, image, output):
willow = image.get_willow_image()
# Make sure we have an output file
if output_file is None:
output_file = BytesIO()
for operation in self.operations:
operation.run(willow, image)
# Write output
backend.save_image(image, output_file, file_format)
willow.save_as_jpeg(output)
# Close the input file
input_file.close()
return output
return output_file
def get_vary(self, image):
vary = []
for operation in self.operations:
if hasattr(operation, 'get_vary'):
vary.extend(operation.get_vary(image))
return vary
def get_vary_key(self, image):
vary_string = '-'.join(self.get_vary(image))
vary_key = hashlib.sha1(vary_string.encode('utf-8')).hexdigest()
return vary_key[:8]
_registered_operations = None
@classmethod
def _search_for_operations(cls):
if cls._registered_operations is not None:
return
operations = []
for fn in hooks.get_hooks('register_image_operations'):
operations.extend(fn())
cls._registered_operations = dict(operations)
class AbstractRendition(models.Model):

View file

@ -0,0 +1,320 @@
import unittest
from wagtail.wagtailimages import image_operations
from wagtail.wagtailimages.exceptions import InvalidFilterSpecError
from wagtail.wagtailimages.models import Image
class WillowOperationRecorder(object):
"""
This class pretends to be a Willow image but instead, it records
the operations that have been performed on the image for testing
"""
def __init__(self, start_size):
self.ran_operations = []
self.start_size = start_size
def __getattr__(self, attr):
def operation(*args, **kwargs):
self.ran_operations.append((attr, args, kwargs))
return operation
def get_size(self):
size = self.start_size
for operation in self.ran_operations:
if operation[0] == 'resize':
size = operation[1]
elif operation[0] == 'crop':
crop = operation[1]
size = crop[2] - crop[0], crop[3] - crop[1]
return size
class ImageOperationTestCase(unittest.TestCase):
operation_class = None
filter_spec_tests = []
filter_spec_error_tests = []
run_tests = []
@classmethod
def make_filter_spec_test(cls, filter_spec, expected_output):
def test_filter_spec(self):
operation = self.operation_class(*filter_spec.split('-'))
# Check the attributes are set correctly
for attr, value in expected_output.items():
self.assertEqual(getattr(operation, attr), value)
test_name = 'test_filter_%s' % filter_spec
test_filter_spec.__name__ = test_name
return test_filter_spec
@classmethod
def make_filter_spec_error_test(cls, filter_spec):
def test_filter_spec_error(self):
self.assertRaises(InvalidFilterSpecError, self.operation_class, *filter_spec.split('-'))
test_name = 'test_filter_%s_raises_%s' % (filter_spec, InvalidFilterSpecError.__name__)
test_filter_spec_error.__name__ = test_name
return test_filter_spec_error
@classmethod
def make_run_test(cls, filter_spec, image, expected_output):
def test_run(self):
# Make operation
operation = self.operation_class(*filter_spec.split('-'))
# Make operation recorder
operation_recorder = WillowOperationRecorder((image.width, image.height))
# Run
operation.run(operation_recorder, image)
# Check
self.assertEqual(operation_recorder.ran_operations, expected_output)
test_name = 'test_run_%s' % filter_spec
test_run.__name__ = test_name
return test_run
@classmethod
def setup_test_methods(cls):
if cls.operation_class is None:
return
# Filter spec tests
for args in cls.filter_spec_tests:
filter_spec_test = cls.make_filter_spec_test(*args)
setattr(cls, filter_spec_test.__name__, filter_spec_test)
# Filter spec error tests
for filter_spec in cls.filter_spec_error_tests:
filter_spec_error_test = cls.make_filter_spec_error_test(filter_spec)
setattr(cls, filter_spec_error_test.__name__, filter_spec_error_test)
# Running tests
for args in cls.run_tests:
run_test = cls.make_run_test(*args)
setattr(cls, run_test.__name__, run_test)
class TestDoNothingOperation(ImageOperationTestCase):
operation_class = image_operations.DoNothingOperation
filter_spec_tests = [
('original', dict()),
('blahblahblah', dict()),
('123456', dict()),
]
filter_spec_error_tests = [
'cannot-take-multiple-parameters',
]
run_tests = [
('original', Image(width=1000, height=1000), []),
]
TestDoNothingOperation.setup_test_methods()
class TestFillOperation(ImageOperationTestCase):
operation_class = image_operations.FillOperation
filter_spec_tests = [
('fill-800x600', dict(width=800, height=600, crop_closeness=0)),
('hello-800x600', dict(width=800, height=600, crop_closeness=0)),
('fill-800x600-c0', dict(width=800, height=600, crop_closeness=0)),
('fill-800x600-c100', dict(width=800, height=600, crop_closeness=1)),
('fill-800x600-c50', dict(width=800, height=600, crop_closeness=0.5)),
('fill-800x600-c1000', dict(width=800, height=600, crop_closeness=1)),
('fill-800000x100', dict(width=800000, height=100, crop_closeness=0)),
]
filter_spec_error_tests = [
'fill',
'fill-800',
'fill-abc',
'fill-800xabc',
'fill-800x600-',
'fill-800x600x10',
'fill-800x600-d100',
]
run_tests = [
# Basic usage
('fill-800x600', Image(width=1000, height=1000), [
('crop', (0, 125, 1000, 875), {}),
('resize', (800, 600), {}),
]),
# Closeness shouldn't have any effect when used without a focal point
('fill-800x600-c100', Image(width=1000, height=1000), [
('crop', (0, 125, 1000, 875), {}),
('resize', (800, 600), {}),
]),
# Should always crop towards focal point. Even if no closeness is set
('fill-80x60', Image(
width=1000,
height=1000,
focal_point_x=1000,
focal_point_y=500,
focal_point_width=0,
focal_point_height=0,
), [
# Crop the largest possible crop box towards the focal point
('crop', (0, 125, 1000, 875), {}),
# Resize it down to final size
('resize', (80, 60), {}),
]),
# Should crop as close as possible without upscaling
('fill-80x60-c100', Image(
width=1000,
height=1000,
focal_point_x=1000,
focal_point_y=500,
focal_point_width=0,
focal_point_height=0,
), [
# Crop as close as possible to the focal point
('crop', (920, 470, 1000, 530), {}),
# No need to resize, crop should've created an 80x60 image
]),
# Ditto with a wide image
# Using a different filter so method name doesn't clash
('fill-100x60-c100', Image(
width=2000,
height=1000,
focal_point_x=2000,
focal_point_y=500,
focal_point_width=0,
focal_point_height=0,
), [
# Crop to the right hand side
('crop', (1900, 470, 2000, 530), {}),
]),
# Make sure that the crop box never enters the focal point
('fill-50x50-c100', Image(
width=2000,
height=1000,
focal_point_x=1000,
focal_point_y=500,
focal_point_width=100,
focal_point_height=20,
), [
# Crop a 100x100 box around the entire focal point
('crop', (950, 450, 1050, 550), {}),
# Resize it down to 50x50
('resize', (50, 50), {}),
]),
# Test that the image is never upscaled
('fill-1000x800', Image(width=100, height=100), [
('crop', (0, 10, 100, 90), {}),
]),
# Test that the crop closeness gets capped to prevent upscaling
('fill-1000x800-c100', Image(
width=1500,
height=1000,
focal_point_x=750,
focal_point_y=500,
focal_point_width=0,
focal_point_height=0,
), [
# Crop a 1000x800 square out of the image as close to the
# focal point as possible. Will not zoom too far in to
# prevent upscaling
('crop', (250, 100, 1250, 900), {}),
]),
# Test for an issue where a ZeroDivisionError would occur when the
# focal point size, image size and filter size match
# See: #797
('fill-1500x1500-c100', Image(
width=1500,
height=1500,
focal_point_x=750,
focal_point_y=750,
focal_point_width=1500,
focal_point_height=1500,
), [
# This operation could probably be optimised out
('crop', (0, 0, 1500, 1500), {}),
])
]
TestFillOperation.setup_test_methods()
class TestMinMaxOperation(ImageOperationTestCase):
operation_class = image_operations.MinMaxOperation
filter_spec_tests = [
('min-800x600', dict(method='min', width=800, height=600)),
('max-800x600', dict(method='max', width=800, height=600)),
]
filter_spec_error_tests = [
'min',
#'hello-800x600',
'min-800',
'min-abc',
'min-800xabc',
'min-800x600-',
'min-800x600-c100',
'min-800x600x10',
]
run_tests = [
# Basic usage of min
('min-800x600', Image(width=1000, height=1000), [
('resize', (800, 800), {}),
]),
# Basic usage of max
('max-800x600', Image(width=1000, height=1000), [
('resize', (600, 600), {}),
]),
]
TestMinMaxOperation.setup_test_methods()
class TestWidthHeightOperation(ImageOperationTestCase):
operation_class = image_operations.WidthHeightOperation
filter_spec_tests = [
('width-800', dict(method='width', size=800)),
('height-600', dict(method='height', size=600)),
]
filter_spec_error_tests = [
'width',
#'hello-800',
'width-800x600',
'width-abc',
'width-800-c100',
]
run_tests = [
# Basic usage of width
('width-400', Image(width=1000, height=500), [
('resize', (400, 200), {}),
]),
# Basic usage of height
('height-400', Image(width=1000, height=500), [
('resize', (800, 400), {}),
]),
]
TestWidthHeightOperation.setup_test_methods()

View file

@ -11,12 +11,10 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.utils import IntegrityError
from django.db import connection
from wagtail.tests.utils import WagtailTestUtils, test_concurrently
from wagtail.tests.utils import WagtailTestUtils
from wagtail.wagtailcore.models import Page
from wagtail.tests.models import EventPage, EventPageCarouselItem
from wagtail.wagtailimages.models import Rendition, Filter, SourceImageIOError
from wagtail.wagtailimages.backends import get_image_backend
from wagtail.wagtailimages.backends.pillow import PillowBackend
from wagtail.wagtailimages.rect import Rect
from .utils import Image, get_test_image_file
@ -124,11 +122,6 @@ class TestRenditions(TestCase):
file=get_test_image_file(),
)
def test_default_backend(self):
# default backend should be pillow
backend = get_image_backend()
self.assertTrue(isinstance(backend, PillowBackend))
def test_minification(self):
rendition = self.image.get_rendition('width-400')
@ -167,59 +160,6 @@ class TestRenditions(TestCase):
self.assertEqual(first_rendition, second_rendition)
class TestRenditionsWand(TestCase):
def setUp(self):
try:
import wand
except ImportError:
# skip these tests if Wand is not installed
raise unittest.SkipTest(
"Skipping image backend tests for wand, as wand is not installed")
# Create an image for running tests on
self.image = Image.objects.create(
title="Test image",
file=get_test_image_file(),
)
self.image.backend = 'wagtail.wagtailimages.backends.wand.WandBackend'
def test_minification(self):
rendition = self.image.get_rendition('width-400')
# Check size
self.assertEqual(rendition.width, 400)
self.assertEqual(rendition.height, 300)
def test_resize_to_max(self):
rendition = self.image.get_rendition('max-100x100')
# Check size
self.assertEqual(rendition.width, 100)
self.assertEqual(rendition.height, 75)
def test_resize_to_min(self):
rendition = self.image.get_rendition('min-120x120')
# Check size
self.assertEqual(rendition.width, 160)
self.assertEqual(rendition.height, 120)
def test_resize_to_original(self):
rendition = self.image.get_rendition('original')
# Check size
self.assertEqual(rendition.width, 640)
self.assertEqual(rendition.height, 480)
def test_cache(self):
# Get two renditions with the same filter
first_rendition = self.image.get_rendition('width-400')
second_rendition = self.image.get_rendition('width-400')
# Check that they are the same object
self.assertEqual(first_rendition, second_rendition)
class TestUsageCount(TestCase):
fixtures = ['wagtail/tests/fixtures/test.json']
@ -432,27 +372,3 @@ class TestIssue312(TestCase):
height=rend1.height,
focal_point_key=rend1.focal_point_key,
)
def test_duplicate_filters(self):
@test_concurrently(10)
def get_renditions():
# Create an image
image = Image.objects.create(
title="Concurrency test image",
file=get_test_image_file(),
)
# get renditions concurrently, using various filters that are unlikely to exist already
for width in range(10, 100, 10):
image.get_rendition('width-%d' % width)
image.delete()
# this block opens multiple database connections, which need to be closed explicitly
# so that we can drop the test database at the end of the test run
connection.close()
get_renditions()
# if the above has completed with no race conditions, there should be precisely one
# of each of the above filters in the database
for width in range(10, 100, 10):
self.assertEqual(Filter.objects.filter(spec='width-%d' % width).count(), 1)

View file

@ -32,7 +32,6 @@ def get_image_json(image):
})
@permission_required('wagtailadmin.access_admin')
def chooser(request):
Image = get_image_model()
@ -100,7 +99,6 @@ def chooser(request):
})
@permission_required('wagtailadmin.access_admin')
def image_chosen(request, image_id):
image = get_object_or_404(get_image_model(), id=image_id)
@ -151,7 +149,6 @@ def chooser_upload(request):
)
@permission_required('wagtailadmin.access_admin')
def chooser_select_format(request, image_id):
image = get_object_or_404(get_image_model(), id=image_id)

View file

@ -5,6 +5,7 @@ from django.core.exceptions import PermissionDenied
from wagtail.wagtailimages.models import get_image_model, Filter
from wagtail.wagtailimages.utils import verify_signature
from wagtail.wagtailimages.exceptions import InvalidFilterSpecError
def serve(request, signature, image_id, filter_spec):
@ -17,5 +18,5 @@ def serve(request, signature, image_id, filter_spec):
rendition = image.get_rendition(filter_spec)
rendition.file.open('rb')
return HttpResponse(FileWrapper(rendition.file), content_type='image/jpeg')
except Filter.InvalidFilterSpecError:
except InvalidFilterSpecError:
return HttpResponse("Invalid filter spec: " + filter_spec, content_type='text/plain', status=400)

View file

@ -18,6 +18,7 @@ from wagtail.wagtailimages.models import get_image_model, Filter
from wagtail.wagtailimages.forms import get_image_form, URLGeneratorForm
from wagtail.wagtailimages.utils import generate_signature
from wagtail.wagtailimages.fields import MAX_UPLOAD_SIZE
from wagtail.wagtailimages.exceptions import InvalidFilterSpecError
@permission_required('wagtailimages.add_image')
@ -77,7 +78,6 @@ def index(request):
})
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def edit(request, image_id):
Image = get_image_model()
ImageForm = get_image_form(Image)
@ -126,7 +126,6 @@ def edit(request, image_id):
})
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def url_generator(request, image_id):
image = get_object_or_404(get_image_model(), id=image_id)
@ -149,7 +148,6 @@ def json_response(document, status=200):
return HttpResponse(json.dumps(document), content_type='application/json', status=status)
@permission_required('wagtailadmin.access_admin')
def generate_url(request, image_id, filter_spec):
# Get the image
Image = get_image_model()
@ -167,7 +165,9 @@ def generate_url(request, image_id, filter_spec):
}, status=403)
# Parse the filter spec to make sure its valid
if not Filter(spec=filter_spec).is_valid():
try:
Filter(spec=filter_spec).operations
except InvalidFilterSpecError:
return json_response({
'error': "Invalid filter spec."
}, status=400)
@ -188,17 +188,15 @@ def generate_url(request, image_id, filter_spec):
return json_response({'url': site_root_url + url, 'preview_url': preview_url}, status=200)
@permission_required('wagtailadmin.access_admin')
def preview(request, image_id, filter_spec):
image = get_object_or_404(get_image_model(), id=image_id)
try:
return Filter(spec=filter_spec).process_image(image.file.file, HttpResponse(content_type='image/jpeg'), focal_point=image.get_focal_point())
except Filter.InvalidFilterSpecError:
return Filter(spec=filter_spec).run(image, HttpResponse(content_type='image/jpeg'))
except InvalidFilterSpecError:
return HttpResponse("Invalid filter spec: " + filter_spec, content_type='text/plain', status=400)
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def delete(request, image_id):
image = get_object_or_404(get_image_model(), id=image_id)
@ -245,7 +243,6 @@ def add(request):
})
@permission_required('wagtailadmin.access_admin')
def usage(request, image_id):
image = get_object_or_404(get_image_model(), id=image_id)

View file

@ -101,7 +101,6 @@ def add(request):
@require_POST
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def edit(request, image_id, callback=None):
Image = get_image_model()
ImageForm = get_image_edit_form(Image)
@ -139,7 +138,6 @@ def edit(request, image_id, callback=None):
@require_POST
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def delete(request, image_id):
image = get_object_or_404(get_image_model(), id=image_id)

View file

@ -10,7 +10,7 @@ from django.contrib.contenttypes.models import ContentType
from wagtail.wagtailcore import hooks
from wagtail.wagtailadmin.menu import MenuItem
from wagtail.wagtailimages import admin_urls
from wagtail.wagtailimages import admin_urls, image_operations
@hooks.register('register_admin_urls')
@ -96,3 +96,15 @@ def register_permissions():
image_content_type = ContentType.objects.get(app_label='wagtailimages', model='image')
image_permissions = Permission.objects.filter(content_type = image_content_type)
return image_permissions
@hooks.register('register_image_operations')
def register_image_operations():
return [
('original', image_operations.DoNothingOperation),
('fill', image_operations.FillOperation),
('min', image_operations.MinMaxOperation),
('max', image_operations.MinMaxOperation),
('width', image_operations.WidthHeightOperation),
('height', image_operations.WidthHeightOperation),
]

View file

@ -12,7 +12,7 @@ from wagtail.wagtailadmin import messages
from wagtail.wagtailredirects import models
REDIRECT_EDIT_HANDLER = ObjectList(models.Redirect.content_panels)
REDIRECT_EDIT_HANDLER = ObjectList(models.Redirect.content_panels).bind_to_model(models.Redirect)
@permission_required('wagtailredirects.change_redirect')

View file

@ -7,7 +7,6 @@ from django.db.models.sql.where import SubqueryConstraint, WhereNode
from django.core.exceptions import ImproperlyConfigured
from wagtail.wagtailsearch.index import class_is_indexed
from wagtail.wagtailsearch.utils import normalise_query_string
class FilterError(Exception):
@ -213,10 +212,6 @@ class BaseSearch(object):
if not class_is_indexed(model):
return []
# Normalise query string
if query_string is not None:
query_string = normalise_query_string(query_string)
# Check that theres still a query string after the clean up
if query_string == "":
return []

View file

@ -278,10 +278,7 @@ class ElasticSearchResults(BaseSearchResults):
hits = self.backend.es.search(**params)
# Get pks from results
pks = [hit['fields']['pk'] for hit in hits['hits']['hits']]
# ElasticSearch 1.x likes to pack pks into lists, unpack them if this has happened
pks = [pk[0] if isinstance(pk, list) else pk for pk in pks]
pks = [hit['fields']['pk'][0] for hit in hits['hits']['hits']]
# Initialise results dictionary
results = dict((str(pk), None) for pk in pks)
@ -298,21 +295,11 @@ class ElasticSearchResults(BaseSearchResults):
# Get query
query = self.query.to_es()
# Elasticsearch 1.x
count = self.backend.es.count(
# Get count
hit_count = self.backend.es.count(
index=self.backend.es_index,
body=dict(query=query),
)
# ElasticSearch 0.90.x fallback
if not count['_shards']['successful'] and "No query registered for [query]]" in count['_shards']['failures'][0]['reason']:
count = self.backend.es.count(
index=self.backend.es_index,
body=query,
)
# Get count
hit_count = count['count']
)['count']
# Add limits
hit_count -= self.start

View file

@ -135,6 +135,34 @@ class TestElasticSearchBackend(BackendTests, TestCase):
# Even though they both start with the letter "H". This should not be considered a match
self.assertEqual(len(results), 0)
def test_search_with_hyphen(self):
"""
This tests that punctuation characters are treated the same
way in both indexing and querying.
See: https://github.com/torchbox/wagtail/issues/937
"""
# Reset the index
self.backend.reset_index()
self.backend.add_type(models.SearchTest)
self.backend.add_type(models.SearchTestChild)
# Add some test data
obj = models.SearchTest()
obj.title = "Hello-World"
obj.live = True
obj.save()
self.backend.add(obj)
# Refresh the index
self.backend.refresh_index()
# Test search for "Hello-World"
results = self.backend.search("Hello-World", models.SearchTest.objects.all())
# Should find the result
self.assertEqual(len(results), 1)
class TestElasticSearchQuery(TestCase):
def assertDictEqual(self, a, b):

View file

@ -1,25 +1,120 @@
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.core import paginator
from wagtail.wagtailcore.models import Page
from wagtail.wagtailsearch.models import Query
from wagtail.tests.models import EventPage
class TestSearchView(TestCase):
def get(self, params={}):
return self.client.get('/search/', params)
fixtures = ['test.json']
def test_simple(self):
response = self.get()
def test_get(self):
response = self.client.get(reverse('wagtailsearch_search'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
def test_search(self):
response = self.get({'q': "Hello"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['query_string'], "Hello")
# Check that search_results/query are set to None
self.assertIsNone(response.context['search_results'])
self.assertIsNone(response.context['query'])
def test_search(self):
response = self.client.get(reverse('wagtailsearch_search') + '?q=Christmas')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
self.assertEqual(response.context['query_string'], "Christmas")
# Check that search_results is an instance of paginator.Page
self.assertIsInstance(response.context['search_results'], paginator.Page)
# Check that the christmas page was in the results (and is the only result)
search_results = response.context['search_results'].object_list
christmas_event_page = Page.objects.get(url_path='/home/events/christmas/')
self.assertEqual(list(search_results), [christmas_event_page])
# Check the query object
self.assertIsInstance(response.context['query'], Query)
query = response.context['query']
self.assertEqual(query.query_string, "christmas")
def pagination_test(test):
def wrapper(*args, **kwargs):
# Create some pages
event_index = Page.objects.get(url_path='/home/events/')
for i in range(100):
event = EventPage(
title="Event " + str(i),
slug='event-' + str(i),
live=True,
)
event_index.add_child(instance=event)
return test(*args, **kwargs)
return wrapper
@pagination_test
def test_get_first_page(self):
response = self.client.get(reverse('wagtailsearch_search') + '?q=Event&page=1')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
# Test that we got the first page
search_results = response.context['search_results']
self.assertEqual(search_results.number, 1)
@pagination_test
def test_get_10th_page(self):
response = self.client.get(reverse('wagtailsearch_search') + '?q=Event&page=10')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
# Test that we got the tenth page
search_results = response.context['search_results']
self.assertEqual(search_results.number, 10)
@pagination_test
def test_get_invalid_page(self):
response = self.client.get(reverse('wagtailsearch_search') + '?q=Event&page=Not a Page')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
# Test that we got the first page
search_results = response.context['search_results']
self.assertEqual(search_results.number, 1)
@pagination_test
def test_get_out_of_range_page(self):
response = self.client.get(reverse('wagtailsearch_search') + '?q=Event&page=9999')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
# Test that we got the last page
search_results = response.context['search_results']
self.assertEqual(search_results.number, search_results.paginator.num_pages)
@pagination_test
def test_get_zero_page(self):
response = self.client.get(reverse('wagtailsearch_search') + '?q=Event&page=0')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
# Test that we got the first page
search_results = response.context['search_results']
self.assertEqual(search_results.number, search_results.paginator.num_pages)
@pagination_test
def test_get_10th_page_backwards_compatibility_with_p(self):
response = self.client.get(reverse('wagtailsearch_search') + '?q=Event&p=10')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailsearch/search_results.html')
# Test that we got the tenth page
search_results = response.context['search_results']
self.assertEqual(search_results.number, 10)
def test_pagination(self):
pages = ['0', '1', '-1', '9999', 'Not a page']
for page in pages:
response = self.get({'p': page})
self.assertEqual(response.status_code, 200)
class TestSuggestionsView(TestCase):

View file

@ -1,5 +1,4 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import permission_required
from django.core.urlresolvers import reverse
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
@ -11,7 +10,6 @@ from wagtail.wagtailadmin.forms import SearchForm
from wagtail.wagtailadmin import messages
@permission_required('wagtailadmin.access_admin')
@vary_on_headers('X-Requested-With')
def index(request):
is_searching = False
@ -70,7 +68,6 @@ def save_editorspicks(query, new_query, editors_pick_formset):
return False
@permission_required('wagtailadmin.access_admin')
def add(request):
if request.POST:
# Get query
@ -102,7 +99,6 @@ def add(request):
})
@permission_required('wagtailadmin.access_admin')
def edit(request, query_id):
query = get_object_or_404(models.Query, id=query_id)
@ -138,7 +134,6 @@ def edit(request, query_id):
})
@permission_required('wagtailadmin.access_admin')
def delete(request, query_id):
query = get_object_or_404(models.Query, id=query_id)

View file

@ -37,7 +37,7 @@ def search(
# Get query string and page from GET paramters
query_string = request.GET.get('q', '')
page = request.GET.get('p', 1)
page = request.GET.get('page', request.GET.get('p', 1))
# Search
if query_string != '':

View file

@ -1,6 +1,5 @@
from django.shortcuts import render
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.contrib.auth.decorators import permission_required
from wagtail.wagtailadmin.modal_workflow import render_modal_workflow
from wagtail.wagtailadmin.forms import SearchForm
@ -9,7 +8,6 @@ from wagtail.wagtailsearch import models
from wagtail.wagtailsearch.utils import normalise_query_string
@permission_required('wagtailadmin.access_admin')
def chooser(request, get_results=False):
# Get most popular queries
queries = models.Query.get_most_popular()

View file

@ -39,9 +39,15 @@ class BaseSnippetChooserPanel(BaseChooserPanel):
}))
def SnippetChooserPanel(field_name, snippet_type):
return type(str('_SnippetChooserPanel'), (BaseSnippetChooserPanel,), {
'field_name': field_name,
'snippet_type_name': force_text(snippet_type._meta.verbose_name),
'snippet_type': snippet_type,
})
class SnippetChooserPanel(object):
def __init__(self, field_name, snippet_type):
self.field_name = field_name
self.snippet_type = snippet_type
def bind_to_model(self, model):
return type(str('_SnippetChooserPanel'), (BaseSnippetChooserPanel,), {
'model': model,
'field_name': self.field_name,
'snippet_type_name': force_text(self.snippet_type._meta.verbose_name),
'snippet_type': self.snippet_type,
})

View file

@ -13,7 +13,7 @@
<div class="row row-flush title">
<h2>
<a href="{% url 'wagtailsnippets_list' content_type.app_label content_type.model %}" class="col6">
{{ name|capfirst }}
{{ name|capfirst }}
</a>
</h2>
<small class="col6">{{ description }}</small>

View file

@ -3,14 +3,12 @@ import json
from six import text_type
from django.shortcuts import get_object_or_404
from django.contrib.auth.decorators import permission_required
from wagtail.wagtailadmin.modal_workflow import render_modal_workflow
from wagtail.wagtailsnippets.views.snippets import get_content_type_from_url_params, get_snippet_type_name
@permission_required('wagtailadmin.access_admin')
def choose(request, content_type_app_name, content_type_model_name):
content_type = get_content_type_from_url_params(content_type_app_name, content_type_model_name)
model = content_type.model_class()
@ -29,7 +27,6 @@ def choose(request, content_type_app_name, content_type_model_name):
)
@permission_required('wagtailadmin.access_admin')
def chosen(request, content_type_app_name, content_type_model_name, id):
content_type = get_content_type_from_url_params(content_type_app_name, content_type_model_name)
model = content_type.model_class()

View file

@ -3,7 +3,6 @@ from django.shortcuts import get_object_or_404, render, redirect
from django.utils.encoding import force_text
from django.utils.text import capfirst
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.decorators import permission_required
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
@ -60,7 +59,7 @@ SNIPPET_EDIT_HANDLERS = {}
def get_snippet_edit_handler(model):
if model not in SNIPPET_EDIT_HANDLERS:
panels = extract_panel_definitions_from_model_class(model)
edit_handler = ObjectList(panels)
edit_handler = ObjectList(panels).bind_to_model(model)
SNIPPET_EDIT_HANDLERS[model] = edit_handler
@ -70,7 +69,6 @@ def get_snippet_edit_handler(model):
# == Views ==
@permission_required('wagtailadmin.access_admin')
def index(request):
snippet_types = [
(
@ -82,11 +80,10 @@ def index(request):
if user_can_edit_snippet_type(request.user, content_type)
]
return render(request, 'wagtailsnippets/snippets/index.html', {
'snippet_types': snippet_types,
'snippet_types': sorted(snippet_types, key=lambda x: x[0].lower()),
})
@permission_required('wagtailadmin.access_admin') # further permissions are enforced within the view
def list(request, content_type_app_name, content_type_model_name):
content_type = get_content_type_from_url_params(content_type_app_name, content_type_model_name)
if not user_can_edit_snippet_type(request.user, content_type):
@ -105,7 +102,6 @@ def list(request, content_type_app_name, content_type_model_name):
})
@permission_required('wagtailadmin.access_admin') # further permissions are enforced within the view
def create(request, content_type_app_name, content_type_model_name):
content_type = get_content_type_from_url_params(content_type_app_name, content_type_model_name)
if not user_can_edit_snippet_type(request.user, content_type):
@ -149,7 +145,6 @@ def create(request, content_type_app_name, content_type_model_name):
})
@permission_required('wagtailadmin.access_admin') # further permissions are enforced within the view
def edit(request, content_type_app_name, content_type_model_name, id):
content_type = get_content_type_from_url_params(content_type_app_name, content_type_model_name)
if not user_can_edit_snippet_type(request.user, content_type):
@ -194,7 +189,6 @@ def edit(request, content_type_app_name, content_type_model_name, id):
})
@permission_required('wagtailadmin.access_admin') # further permissions are enforced within the view
def delete(request, content_type_app_name, content_type_model_name, id):
content_type = get_content_type_from_url_params(content_type_app_name, content_type_model_name)
if not user_can_edit_snippet_type(request.user, content_type):
@ -223,7 +217,6 @@ def delete(request, content_type_app_name, content_type_model_name, id):
})
@permission_required('wagtailadmin.access_admin')
def usage(request, content_type_app_name, content_type_model_name, id):
content_type = get_content_type_from_url_params(content_type_app_name, content_type_model_name)
model = content_type.model_class()