mirror of
https://github.com/Hopiu/wagtail.git
synced 2026-05-12 09:13:14 +00:00
Merge branch 'master' into feature/streamfield-frontend
Conflicts: wagtail/wagtailadmin/edit_handlers.py
This commit is contained in:
commit
00f50781d1
76 changed files with 1869 additions and 24911 deletions
24
.travis.yml
24
.travis.yml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ Contributors
|
|||
* linibou
|
||||
* Timo Rieber
|
||||
* Jerel Unruh
|
||||
* georgewhewell
|
||||
* Frank Wiles
|
||||
* Sebastian Spiegel
|
||||
|
||||
Translators
|
||||
===========
|
||||
|
|
|
|||
|
|
@ -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>`_.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
49
docs/howto/custom_branding.rst
Normal file
49
docs/howto/custom_branding.rst
Normal 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 %}
|
||||
|
|
@ -9,4 +9,6 @@ How to
|
|||
deploying
|
||||
performance
|
||||
multilingual_sites
|
||||
custom_branding
|
||||
contributing
|
||||
third_party_tutorials
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
28
docs/howto/third_party_tutorials.rst
Normal file
28
docs/howto/third_party_tutorials.rst
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
setup.py
5
setup.py
|
|
@ -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
106
tox.ini
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
wagtail/utils/urlpatterns.py
Normal file
9
wagtail/utils/urlpatterns.py
Normal 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
|
||||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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, )))
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')]
|
||||
|
|
|
|||
14
wagtail/wagtailcore/tests/test_validators.py
Normal file
14
wagtail/wagtailcore/tests/test_validators.py
Normal 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)
|
||||
15
wagtail/wagtailcore/validators.py
Normal file
15
wagtail/wagtailcore/validators.py
Normal 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'))
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
2
wagtail/wagtailimages/exceptions.py
Normal file
2
wagtail/wagtailimages/exceptions.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class InvalidFilterSpecError(ValueError):
|
||||
pass
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 []
|
||||
265
wagtail/wagtailimages/image_operations.py
Normal file
265
wagtail/wagtailimages/image_operations.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
320
wagtail/wagtailimages/tests/test_image_operations.py
Normal file
320
wagtail/wagtailimages/tests/test_image_operations.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 != '':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue