Merge branch 'master' into support-phone-number-links

This commit is contained in:
Mikael Engström 2019-08-21 21:36:12 +02:00 committed by GitHub
commit 7e7ca39821
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
114 changed files with 1799 additions and 670 deletions

View file

@ -1,6 +1,6 @@
language: python
cache: pip
dist: trusty
dist: xenial
addons:
postgresql: "9.6"
@ -17,22 +17,18 @@ matrix:
python: 3.6
- env: TOXENV=py37-dj21-postgres-noelasticsearch
python: 3.7
dist: xenial
- env: TOXENV=py37-dj22-sqlite-noelasticsearch
python: 3.7
dist: xenial
- env: TOXENV=py37-dj22-mysql-noelasticsearch
python: 3.7
dist: xenial
- env: TOXENV=py37-dj22-postgres-noelasticsearch
python: 3.7
dist: xenial
- env: TOXENV=py37-dj22stable-postgres-noelasticsearch
python: 3.7
dist: xenial
- env: TOXENV=py37-djmaster-postgres-noelasticsearch
python: 3.7
dist: xenial
- env: TOXENV=py38-dj22-postgres-noelasticsearch
python: 3.8-dev
- env: TOXENV=py36-dj20-sqlite-elasticsearch2 INSTALL_ELASTICSEARCH2=yes
python: 3.6
- env: TOXENV=py36-dj21-sqlite-elasticsearch2 INSTALL_ELASTICSEARCH2=yes
@ -45,7 +41,6 @@ matrix:
python: 3.6
- env: TOXENV=py37-dj22-postgres-elasticsearch6 INSTALL_ELASTICSEARCH6=yes
python: 3.7
dist: xenial
allow_failures:
# Ignore failures on Elasticsearch tests because ES on Travis is intermittently flaky
- env: TOXENV=py36-dj20-sqlite-elasticsearch2 INSTALL_ELASTICSEARCH2=yes
@ -58,6 +53,8 @@ matrix:
- env: TOXENV=py37-dj22stable-postgres-noelasticsearch
# allow failures against Django master
- env: TOXENV=py37-djmaster-postgres-noelasticsearch
# allow failures against Python 3.8-dev
- env: TOXENV=py38-dj22-postgres-noelasticsearch
# Services
services:

View file

@ -6,9 +6,32 @@ Changelog
* Added `construct_page_listing_buttons` hook (Michael van Tellingen)
* Added more detailed documentation and troubleshooting for installing OpenCV for feature detection (Daniele Procida)
* Added Table Block caption for accessibility (Rahmi Pruitt)
* Move and refactor upgrade notification JS (Jonny Scholes)
* Add ability to insert internal anchor links/links with fragment identifiers in Draftail (rich text) fields (Iman Syed)
* Remove need for Elasticsearch `update_all_types` workaround, upgrade minimum release to 6.4.0 or above (Jonathan Liuti)
* Upgrade django-modelcluster to>=5.0 and upgrade django-taggit to >=1.0 for Django 3.0 support (Matt Westcott)
* Revise tests to ensure all pass in Django 3.0 (Matt Westcott)
* Add ability for users to change their own name via the account settings page (Kevin Howbrook)
* Fix: Added line breaks to long filenames on multiple image / document uploader (Kevin Howbrook)
* Fix: Added https support for Scribd oEmbed provider (Rodrigo)
* Fix: Changed StreamField group labels color so labels are visible (Catherine Farman)
* Fix: Prevented images with a very wide aspect ratio from being displayed distorted in the rich text editor (Iman Syed)
* Fix: Prevent exception when deleting a model with a protected One-to-one relationship (Neal Todd)
* Fix: Added labels to snippet bulk edit checkboxes for screen reader users (Martey Dodoo)
* Fix: Middleware responses during page preview are now properly returned to the user (Matt Westcott)
* Fix: Default text of page links in rich text uses the public page title rather than the admin display title (Andy Chosak)
* Fix: Specific page permission checks are now enforced when viewing a page revision (Andy Chosak)
* Fix: `pageurl` and `slugurl` tags no longer fail when `request.site` is `None` (Samir Shah)
* Fix: Output form media on add/edit image forms with custom models (Matt Westcott)
* Fix: Layout for the clear checkbox in default FileField widget (Mikalai Radchuk)
* Fix: Remove ASCII conversion from Postgres search backend, to support stemming in non-Latin alphabets (Pavel Denisov)
2.6.1 (05.08.2019)
~~~~~~~~~~~~~~~~~~
* Fix: Prevent Javascript errors caused by unescaped quote characters in translation strings (Matt Westcott)
2.6 (01.08.2019)

View file

@ -384,6 +384,12 @@ Contributors
* William Blackie
* Andrew Miller
* Rodrigo
* Iman Syed
* John Carter
* Jonathan Liuti
* Rahmi Pruitt
* Sanyam Khurana
* Pavel Denisov
Translators
===========

View file

@ -105,6 +105,27 @@ select::-ms-expand {
display: none;
}
.file_field {
.input {
label {
float: none;
display: inline;
padding: 0;
}
input[type=checkbox] {
margin-top: 5px;
}
a {
&:after {
content: ' ';
display: block;
}
}
}
}
// radio and check boxes
input[type=radio],

View file

@ -25,7 +25,8 @@
@mixin invalid-image-fallback {
min-width: 256px;
min-height: 100px;
min-height: 50px;
object-fit: contain;
background-color: $color-grey-1;
}

View file

@ -15,7 +15,7 @@ const getEmailAddress = mailto => mailto.replace('mailto:', '').split('?')[0];
const getPhoneNumber = tel => tel.replace('tel:', '').split('?')[0];
const getDomainName = url => url.replace(/(^\w+:|^)\/\//, '').split('/')[0];
// Determines how to display the link based on its type: page, mail, or external.
// Determines how to display the link based on its type: page, mail, anchor or external.
export const getLinkAttributes = (data) => {
const url = data.url || null;
let icon;
@ -33,6 +33,9 @@ export const getLinkAttributes = (data) => {
} else if (url.startsWith('tel:')) {
icon = LINK_ICON;
label = getPhoneNumber(url);
} else if (url.startsWith('#')) {
icon = LINK_ICON;
label = url;
} else {
icon = LINK_ICON;
label = getDomainName(url);

View file

@ -63,6 +63,13 @@ describe('Link', () => {
});
});
it('anchor', () => {
expect(getLinkAttributes({ url: '#testanchor' })).toMatchObject({
url: '#testanchor',
label: '#testanchor',
});
});
it('external', () => {
expect(getLinkAttributes({ url: 'http://www.ex.com/' })).toMatchObject({
url: 'http://www.ex.com/',

View file

@ -43,6 +43,7 @@ export const getChooserConfig = (entityType, entity, selectedText) => {
allow_external_link: true,
allow_email_link: true,
allow_phone_link: true,
allow_anchor_link: true,
link_text: selectedText,
};
@ -61,6 +62,9 @@ export const getChooserConfig = (entityType, entity, selectedText) => {
} else if (data.url.startsWith('tel:')) {
url = global.chooserUrls.phoneLinkChooser;
urlParams.link_url = data.url.replace('tel:', '');
} else if (data.url.startsWith('#')) {
url = global.chooserUrls.anchorLinkChooser;
urlParams.link_url = data.url.replace('#', '');
} else {
url = global.chooserUrls.externalLinkChooser;
urlParams.link_url = data.url;

View file

@ -144,6 +144,14 @@ describe('ModalWorkflowSource', () => {
})).toMatchSnapshot();
});
it('anchor', () => {
expect(filterEntityData({ type: 'LINK' }, {
prefer_this_title_as_link_text: false,
title: 'testanchor',
url: '#testanchor',
})).toMatchSnapshot();
});
it('external', () => {
expect(filterEntityData({ type: 'LINK' }, {
prefer_this_title_as_link_text: false,

View file

@ -28,6 +28,12 @@ Object {
}
`;
exports[`ModalWorkflowSource #filterEntityData LINK anchor 1`] = `
Object {
"url": "#testanchor",
}
`;
exports[`ModalWorkflowSource #filterEntityData LINK external 1`] = `
Object {
"url": "https://www.example.com/",
@ -55,6 +61,7 @@ Object {
},
"url": "/admin/choose-external-link/",
"urlParams": Object {
"allow_anchor_link": true,
"allow_email_link": true,
"allow_external_link": true,
"allow_phone_link": true,
@ -72,6 +79,7 @@ Object {
},
"url": "/admin/choose-email-link/",
"urlParams": Object {
"allow_anchor_link": true,
"allow_email_link": true,
"allow_external_link": true,
"allow_phone_link": true,
@ -89,6 +97,7 @@ Object {
},
"url": "/admin/choose-page/",
"urlParams": Object {
"allow_anchor_link": true,
"allow_email_link": true,
"allow_external_link": true,
"allow_phone_link": true,
@ -105,6 +114,7 @@ Object {
},
"url": "/admin/choose-page/1/",
"urlParams": Object {
"allow_anchor_link": true,
"allow_email_link": true,
"allow_external_link": true,
"allow_phone_link": true,
@ -121,6 +131,7 @@ Object {
},
"url": "/admin/choose-page/",
"urlParams": Object {
"allow_anchor_link": true,
"allow_email_link": true,
"allow_external_link": true,
"allow_phone_link": true,

View file

@ -0,0 +1,40 @@
import { versionOutOfDate } from '../../utils/version';
const initUpgradeNotification = () => {
const container = document.querySelector('[data-upgrade]');
if (!container) {
return;
}
/*
* Expected JSON payload:
* {
* "version" : "1.2.3", // Version number. Can only contain numbers and decimal point.
* "url" : "https://wagtail.io" // Absolute URL to page/file containing release notes or actual package. It's up to you.
* }
*/
const releasesUrl = 'https://releases.wagtail.io/latest.txt';
const currentVersion = container.dataset.wagtailVersion;
fetch(releasesUrl).then(response => {
if (response.status !== 200) {
// eslint-disable-next-line no-console
console.log(`Unexpected response from ${releasesUrl}. Status: ${response.status}`);
return false;
}
return response.json();
}).then(data => {
if (data && data.version && versionOutOfDate(data.version, currentVersion)) {
container.querySelector('[data-upgrade-version]').innerText = data.version;
container.querySelector('[data-upgrade-link]').setAttribute('href', data.url);
container.style.display = '';
}
})
.catch(err => {
// eslint-disable-next-line no-console
console.log(`Error fetching ${releasesUrl}. Error: ${err}`);
});
};
export { initUpgradeNotification };

View file

@ -4,25 +4,27 @@
*/
import Button from './components/Button/Button';
import Explorer, { ExplorerToggle, initExplorer } from './components/Explorer';
import Icon from './components/Icon/Icon';
import PublicationStatus from './components/PublicationStatus/PublicationStatus';
import LoadingSpinner from './components/LoadingSpinner/LoadingSpinner';
import Portal from './components/Portal/Portal';
import PublicationStatus from './components/PublicationStatus/PublicationStatus';
import Transition from './components/Transition/Transition';
import Explorer, { ExplorerToggle, initExplorer } from './components/Explorer';
import { initFocusOutline } from './utils/focus';
import { initSubmenus } from './includes/initSubmenus';
import { initUpgradeNotification } from './components/UpgradeNotification';
export {
Button,
Icon,
PublicationStatus,
LoadingSpinner,
Portal,
Transition,
Explorer,
ExplorerToggle,
Icon,
LoadingSpinner,
Portal,
PublicationStatus,
Transition,
initExplorer,
initFocusOutline,
initSubmenus,
initUpgradeNotification,
};

View file

@ -0,0 +1,18 @@
import { versionOutOfDate } from './version';
describe('wagtail package utils', () => {
describe('version.versionOutOfDate', () => {
it('compares 1.5 and 2.4 correctly', () => {
expect(versionOutOfDate('1.5', '2.4')).toBeFalsy();
});
it('compares 1.5.4 and 1.5.5 correctly', () => {
expect(versionOutOfDate('1.5.4', '1.5.5')).toBeFalsy();
});
it('compares 1.5 and 1.5 correctly', () => {
expect(versionOutOfDate('1.5', '1.5')).toBeFalsy();
});
it('compares 2.6a0 and 2.4 correctly', () => {
expect(versionOutOfDate('2.6a0', '2.4')).toBeTruthy();
});
});
});

View file

@ -0,0 +1,23 @@
function compareVersion(versionA, versionB) {
const re = /(\.0)+[^\.]*$/;
const va = (versionA + '').replace(re, '').split('.');
const vb = (versionB + '').replace(re, '').split('.');
const len = Math.min(va.length, vb.length);
for (let i = 0; i < len; i++) {
const cmp = parseInt(va[i], 10) - parseInt(vb[i], 10);
if (cmp !== 0) {
return cmp;
}
}
return va.length - vb.length;
}
function versionOutOfDate(latestVersion, currentVersion) {
return compareVersion(latestVersion, currentVersion) > 0;
}
export {
compareVersion,
versionOutOfDate,
};

View file

@ -55,6 +55,7 @@ global.wagtail = {};
global.chooserUrls = {
documentChooser: '/admin/documents/chooser/',
emailLinkChooser: '/admin/choose-email-link/',
anchorLinkChooser: '/admin/choose-anchor-link',
embedsChooser: '/admin/embeds/chooser/',
externalLinkChooser: '/admin/choose-external-link/',
imageChooser: '/admin/images/chooser/',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -159,6 +159,7 @@ or to add custom validation logic for your models:
.. code-block:: python
from django import forms
from django.db import models
import geocoder # not in Wagtail, for example only - http://geocoder.readthedocs.io/
from wagtail.admin.edit_handlers import FieldPanel
from wagtail.admin.forms import WagtailAdminPageForm

View file

@ -87,7 +87,7 @@ You can create custom rewrite handlers to support your own new ``linktype`` and
Required. The ``identifier`` attribute is a string that indicates which rich text tags should be handled by this handler.
For example, ``PageLinkHandler.get_identifier`` returns the string ``"page"``, indicating that any rich text tags with ``<a linktype="page">`` should be handled by it.
For example, ``PageLinkHandler.identifier`` is set to the string ``"page"``, indicating that any rich text tags with ``<a linktype="page">`` should be handled by it.
.. method:: expand_db_attributes(attrs)

View file

@ -2,8 +2,7 @@
Committing code
===============
This section is for the committers of Wagtail,
or for anyone interested in the process of getting code committed to Wagtail.
**This section is for the core team of Wagtail, or for anyone interested in the process of getting code committed to Wagtail.**
Code should only be committed after it has been reviewed
by at least one other reviewer or committer,
@ -67,6 +66,10 @@ depending on which will be more readable in the commit history.
Update ``CHANGELOG.txt`` and release notes
==========================================
.. note::
This should only be done by core committers, once the changes have been reviewed and accepted.
Every significant change to Wagtail should get an entry in the ``CHANGELOG.txt``,
and the release notes for the current version.

View file

@ -716,7 +716,7 @@ If you want to change the content of the email that is sent when a form submits
To do this, you need to:
* Ensure you have your form model defined that extends ``wagtail.contrib.forms.models.AbstractEmailForm``.
* In your models.py file, import the ``wagtail.admin.utils.send_mail`` function.
* In your models.py file, import the ``wagtail.admin.mail.send_mail`` function.
* Override the ``send_mail`` method in your page model.
Example:
@ -725,7 +725,7 @@ Example:
from datetime import date
# ... additional wagtail imports
from wagtail.admin.utils import send_mail
from wagtail.admin.mail import send_mail
from wagtail.contrib.forms.models import AbstractEmailForm
@ -763,6 +763,6 @@ Example:
# Content is joined with a new line to separate each text line
content = '\n'.join(content)
# wagtail.wagtailadmin.utils - send_mail function is called
# wagtail.admin.mail - send_mail function is called
# This function extends the Django default send_mail function
send_mail(subject, content, addresses, self.from_address)

View file

@ -2,7 +2,7 @@
TableBlock
==========
The TableBlock module provides an HTML table block type for StreamField. This module uses `handsontable 6.2.2 <https://handsontable.com/>`_ to provide users with the ability to create and edit HTML tables in Wagtail.
The TableBlock module provides an HTML table block type for StreamField. This module uses `handsontable 6.2.2 <https://handsontable.com/>`_ to provide users with the ability to create and edit HTML tables in Wagtail. Table blocks provides a caption field for accessibility.
.. image:: ../../_static/images/screen40_table_block.png
@ -83,7 +83,7 @@ Every key in the ``table_options`` dictionary maps to a `handsontable <https://h
* `startCols <https://handsontable.com/docs/6.2.2/Options.html#startCols>`_ - The default number of columns for new tables.
* `colHeaders <https://handsontable.com/docs/6.2.2/Options.html#colHeaders>`_ - Can be set to ``True`` or ``False``. This setting designates if new tables should be created with column headers. **Note:** this only sets the behaviour for newly created tables. Page editors can override this by checking the the “Column header” checkbox in the table editor in the Wagtail admin.
* `rowHeaders <https://handsontable.com/docs/6.2.2/Options.html#rowHeaders>`_ - Operates the same as ``colHeaders`` to designate if new tables should be created with the first column as a row header. Just like ``colHeaders`` this option can be overridden by the page editor in the Wagtail admin.
* `contextMenu <https://handsontable.com/docs/6.2.2/Options.html#contextMenu>`_ - Enables or disables the Handsontable right-click menu. By default this is set to ``True``. Alternatively you can provide a list or a dictionary with [specific options](https://handsontable.com/docs/6.2.2/demo-context-menu.html#page-specific).
* `contextMenu <https://handsontable.com/docs/6.2.2/Options.html#contextMenu>`_ - Enables or disables the Handsontable right-click menu. By default this is set to ``True``. Alternatively you can provide a list or a dictionary with [specific options](https://handsontable.com/docs/6.2.2/demo-context-menu.html#page-specific).
* `editor <https://handsontable.com/docs/6.2.2/Options.html#editor>`_ - Defines the editor used for table cells. The default setting is text.
* `stretchH <https://handsontable.com/docs/6.2.2/Options.html#stretchH>`_ - Sets the default horizontal resizing of tables. Options include, 'none', 'last', and 'all'. By default TableBlock uses 'all' for the even resizing of columns.
* `height <https://handsontable.com/docs/6.2.2/Options.html#height>`_ - The default height of the grid. By default TableBlock sets the height to ``108`` for the optimal appearance of new tables in the editor. This is optimized for tables with ``startRows`` set to ``3``. If you change the number of ``startRows`` in the configuration, you might need to change the ``height`` setting to improve the default appearance in the editor.

16
docs/releases/2.6.1.rst Normal file
View file

@ -0,0 +1,16 @@
===========================
Wagtail 2.6.1 release notes
===========================
.. contents::
:local:
:depth: 1
What's new
==========
Bug fixes
~~~~~~~~~
* Prevent Javascript errors caused by unescaped quote characters in translation strings (Matt Westcott)

View file

@ -13,12 +13,22 @@ Wagtail 2.7 is designated a Long Term Support (LTS) release. Long Term Support r
What's new
==========
* Upgraded Elasticsearch client library dependency to 6.4.0 or above, 7.0.0 or above is still supported.
Other features
~~~~~~~~~~~~~~
* Added ``construct_page_listing_buttons`` hook (Michael van Tellingen)
* Added more detailed documentation and troubleshooting for installing OpenCV for feature detection (Daniele Procida)
* Move and refactor upgrade notification JS (Jonny Scholes)
* Remove need for Elasticsearch ``update_all_types`` workaround, upgrade minimum release to 6.4.0 or above (Jonathan Liuti)
* Add ability to insert internal anchor links/links with fragment identifiers in Draftail (rich text) fields (Iman Syed)
* Added Table Block caption for accessibility (Rahmi Pruitt)
* Upgrade django-modelcluster to>=5.0 and upgrade django-taggit to >=1.0 for Django 3.0 support (Matt Westcott)
* Revise tests to ensure all pass in Django 3.0 (Matt Westcott)
* Add ability for users to change their own name via the account settings page (Kevin Howbrook)
Bug fixes
@ -27,7 +37,68 @@ Bug fixes
* Added line breaks to long filenames on multiple image / document uploader (Kevin Howbrook)
* Added https support for Scribd oEmbed provider (Rodrigo)
* Changed StreamField group label color so labels are visible (Catherine Farman)
* Prevented images with a very wide aspect ratio from being displayed distorted in the rich text editor (Iman Syed)
* Prevent exception when deleting a model with a protected One-to-one relationship (Neal Todd)
* Added labels to snippet bulk edit checkboxes for screen reader users (Martey Dodoo)
* Middleware responses during page preview are now properly returned to the user (Matt Westcott)
* Default text of page links in rich text uses the public page title rather than the admin display title (Andy Chosak)
* Specific page permission checks are now enforced when viewing a page revision (Andy Chosak)
* ``pageurl`` and ``slugurl`` tags no longer fail when ``request.site`` is ``None`` (Samir Shah)
* Output form media on add/edit image forms with custom models (Matt Westcott)
* Fixes layout for the clear checkbox in default FileField widget (Mikalai Radchuk)
* Remove ASCII conversion from Postgres search backend, to support stemming in non-Latin alphabets (Pavel Denisov)
Upgrade considerations
======================
``Page.dummy_request`` is deprecated
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The internal ``Page.dummy_request`` method (which generates an HTTP request object simulating a real page request, for use in previews) has been deprecated, as it did not correctly handle errors generated during middleware processing. Any code that calls this method to render page previews should be updated to use the new method ``Page.make_preview_request(original_request=None, preview_mode=None)``, which builds the request and calls ``Page.serve_preview`` as a single operation.
``wagtail.admin.utils`` and ``wagtail.admin.decorators`` modules deprecated
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The modules ``wagtail.admin.utils`` and ``wagtail.admin.decorators`` have been deprecated. The helper functions defined here exist primarily for Wagtail's internal use; however, some of them (particularly ``send_mail`` and ``permission_required``) may be found in user code, and import lines will need to be updated. The new locations for these definitions are as follows:
+---------------------------------+--------------------------+--------------------------+
| Definition | Old location | New location |
+=================================+==========================+==========================+
| any_permission_required | wagtail.admin.utils | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| permission_denied | wagtail.admin.utils | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| permission_required | wagtail.admin.utils | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| PermissionPolicyChecker | wagtail.admin.utils | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| user_has_any_page_permission | wagtail.admin.utils | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| user_passes_test | wagtail.admin.utils | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| users_with_page_permission | wagtail.admin.utils | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| reject_request | wagtail.admin.decorators | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| require_admin_access | wagtail.admin.decorators | wagtail.admin.auth |
+---------------------------------+--------------------------+--------------------------+
| get_available_admin_languages | wagtail.admin.utils | wagtail.admin.locale |
+---------------------------------+--------------------------+--------------------------+
| get_available_admin_time_zones | wagtail.admin.utils | wagtail.admin.locale |
+---------------------------------+--------------------------+--------------------------+
| get_js_translation_strings | wagtail.admin.utils | wagtail.admin.locale |
+---------------------------------+--------------------------+--------------------------+
| WAGTAILADMIN_PROVIDED_LANGUAGES | wagtail.admin.utils | wagtail.admin.locale |
+---------------------------------+--------------------------+--------------------------+
| send_mail | wagtail.admin.utils | wagtail.admin.mail |
+---------------------------------+--------------------------+--------------------------+
| send_notification | wagtail.admin.utils | wagtail.admin.mail |
+---------------------------------+--------------------------+--------------------------+
| get_object_usage | wagtail.admin.utils | wagtail.admin.models |
+---------------------------------+--------------------------+--------------------------+
| popular_tags_for_model | wagtail.admin.utils | wagtail.admin.models |
+---------------------------------+--------------------------+--------------------------+
| get_site_for_user | wagtail.admin.utils | wagtail.admin.navigation |
+---------------------------------+--------------------------+--------------------------+

View file

@ -6,6 +6,7 @@ Release notes
upgrading
2.7
2.6.1
2.6
2.5.2
2.5.1

View file

@ -113,11 +113,12 @@ Prerequisites are the `Elasticsearch`_ service itself and, via pip, the `elastic
.. code-block:: sh
pip install "elasticsearch>=6.0.0,<6.3.1" # for Elasticsearch 6.x
pip install "elasticsearch>=6.4.0,<7.0.0" # for Elasticsearch 6.x
.. warning::
| Version 6.3.1 of the Elasticsearch client library is incompatible with Wagtail. Use 6.3.0 or earlier.
| Version 6.3.1 of the Elasticsearch client library is incompatible with Wagtail. Use 6.4.0 or above.
The backend is configured in settings:

View file

@ -22,8 +22,8 @@ except ImportError:
install_requires = [
"Django>=2.0,<2.3",
"django-modelcluster>=4.2,<5.0",
"django-taggit>=0.23,<1.0",
"django-modelcluster>=5.0,<6.0",
"django-taggit>=1.0,<2.0",
"django-treebeard>=4.2.0,<5.0",
"djangorestframework>=3.7.4,<4.0",
"draftjs_exporter>=2.1.5,<3.0",

View file

@ -36,6 +36,7 @@ basepython =
py35: python3.5
py36: python3.6
py37: python3.7
py38: python3.8
deps =
django-sendfile==0.3.6
@ -52,7 +53,7 @@ deps =
elasticsearch2: elasticsearch>=2,<3
elasticsearch5: elasticsearch>=5,<6
elasticsearch5: certifi
elasticsearch6: elasticsearch>=6,<6.3.1
elasticsearch6: elasticsearch>=6.4.0,<7
elasticsearch6: certifi
setenv =

170
wagtail/admin/auth.py Normal file
View file

@ -0,0 +1,170 @@
from functools import wraps
from django.contrib.auth import get_user_model
from django.contrib.auth.views import redirect_to_login as auth_redirect_to_login
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.timezone import activate as activate_tz
from django.utils.translation import activate as activate_lang
from django.utils.translation import ugettext as _
from wagtail.admin import messages
from wagtail.core.models import GroupPagePermission
from wagtail.utils import l18n
def users_with_page_permission(page, permission_type, include_superusers=True):
# Get user model
User = get_user_model()
# Find GroupPagePermission records of the given type that apply to this page or an ancestor
ancestors_and_self = list(page.get_ancestors()) + [page]
perm = GroupPagePermission.objects.filter(permission_type=permission_type, page__in=ancestors_and_self)
q = Q(groups__page_permissions__in=perm)
# Include superusers
if include_superusers:
q |= Q(is_superuser=True)
return User.objects.filter(is_active=True).filter(q).distinct()
def permission_denied(request):
"""Return a standard 'permission denied' response"""
if request.is_ajax():
raise PermissionDenied
from wagtail.admin import messages
messages.error(request, _('Sorry, you do not have permission to access this area.'))
return redirect('wagtailadmin_home')
def user_passes_test(test):
"""
Given a test function that takes a user object and returns a boolean,
return a view decorator that denies access to the user if the test returns false.
"""
def decorator(view_func):
# decorator takes the view function, and returns the view wrapped in
# a permission check
@wraps(view_func)
def wrapped_view_func(request, *args, **kwargs):
if test(request.user):
# permission check succeeds; run the view function as normal
return view_func(request, *args, **kwargs)
else:
# permission check failed
return permission_denied(request)
return wrapped_view_func
return decorator
def permission_required(permission_name):
"""
Replacement for django.contrib.auth.decorators.permission_required which returns a
more meaningful 'permission denied' response than just redirecting to the login page.
(The latter doesn't work anyway because Wagtail doesn't define LOGIN_URL...)
"""
def test(user):
return user.has_perm(permission_name)
# user_passes_test constructs a decorator function specific to the above test function
return user_passes_test(test)
def any_permission_required(*perms):
"""
Decorator that accepts a list of permission names, and allows the user
to pass if they have *any* of the permissions in the list
"""
def test(user):
for perm in perms:
if user.has_perm(perm):
return True
return False
return user_passes_test(test)
class PermissionPolicyChecker:
"""
Provides a view decorator that enforces the given permission policy,
returning the wagtailadmin 'permission denied' response if permission not granted
"""
def __init__(self, policy):
self.policy = policy
def require(self, action):
def test(user):
return self.policy.user_has_permission(user, action)
return user_passes_test(test)
def require_any(self, *actions):
def test(user):
return self.policy.user_has_any_permission(user, actions)
return user_passes_test(test)
def user_has_any_page_permission(user):
"""
Check if a user has any permission to add, edit, or otherwise manage any
page.
"""
# Can't do nothin if you're not active.
if not user.is_active:
return False
# Superusers can do anything.
if user.is_superuser:
return True
# At least one of the users groups has a GroupPagePermission.
# The user can probably do something.
if GroupPagePermission.objects.filter(group__in=user.groups.all()).exists():
return True
# Specific permissions for a page type do not mean anything.
# No luck! This user can not do anything with pages.
return False
def reject_request(request):
if request.is_ajax():
raise PermissionDenied
return auth_redirect_to_login(
request.get_full_path(), login_url=reverse('wagtailadmin_login'))
def require_admin_access(view_func):
def decorated_view(request, *args, **kwargs):
user = request.user
if user.is_anonymous:
return reject_request(request)
if user.has_perms(['wagtailadmin.access_admin']):
if hasattr(user, 'wagtail_userprofile'):
language = user.wagtail_userprofile.get_preferred_language()
l18n.set_language(language)
activate_lang(language)
time_zone = user.wagtail_userprofile.get_current_time_zone()
activate_tz(time_zone)
return view_func(request, *args, **kwargs)
if not request.is_ajax():
messages.error(request, _('You do not have permission to access the admin'))
return reject_request(request)
return decorated_view

View file

@ -274,19 +274,6 @@ class M2MFieldComparison(FieldComparison):
class TagsFieldComparison(M2MFieldComparison):
def get_items(self):
tags_a = [
tag.tag
for tag in self.val_a
]
tags_b = [
tag.tag
for tag in self.val_b
]
return tags_a, tags_b
def get_item_display(self, tag):
return tag.slug

View file

@ -1,41 +1,9 @@
from django.contrib.auth.views import redirect_to_login as auth_redirect_to_login
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.timezone import activate as activate_tz
from django.utils.translation import activate as activate_lang
from django.utils.translation import ugettext as _
import sys
from wagtail.utils.deprecation import MovedDefinitionHandler, RemovedInWagtail29Warning
from wagtail.admin import messages
from wagtail.utils import l18n
MOVED_DEFINITIONS = {
'reject_request': 'wagtail.admin.auth',
'require_admin_access': 'wagtail.admin.auth',
}
def reject_request(request):
if request.is_ajax():
raise PermissionDenied
return auth_redirect_to_login(
request.get_full_path(), login_url=reverse('wagtailadmin_login'))
def require_admin_access(view_func):
def decorated_view(request, *args, **kwargs):
user = request.user
if user.is_anonymous:
return reject_request(request)
if user.has_perms(['wagtailadmin.access_admin']):
if hasattr(user, 'wagtail_userprofile'):
language = user.wagtail_userprofile.get_preferred_language()
l18n.set_language(language)
activate_lang(language)
time_zone = user.wagtail_userprofile.get_current_time_zone()
activate_tz(time_zone)
return view_func(request, *args, **kwargs)
if not request.is_ajax():
messages.error(request, _('You do not have permission to access the admin'))
return reject_request(request)
return decorated_view
sys.modules[__name__] = MovedDefinitionHandler(sys.modules[__name__], MOVED_DEFINITIONS, RemovedInWagtail29Warning)

View file

@ -27,7 +27,12 @@ class URLOrAbsolutePathField(forms.URLField):
class ExternalLinkChooserForm(forms.Form):
url = URLOrAbsolutePathField(required=True, label=ugettext_lazy("URL"))
url = URLOrAbsolutePathField(required=True, label=ugettext_lazy(""))
link_text = forms.CharField(required=False)
class AnchorLinkChooserForm(forms.Form):
url = forms.CharField(required=True, label=ugettext_lazy("#"))
link_text = forms.CharField(required=False)

116
wagtail/admin/locale.py Normal file
View file

@ -0,0 +1,116 @@
import pytz
from django.conf import settings
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy
# Wagtail languages with >=90% coverage
# This list is manually maintained
WAGTAILADMIN_PROVIDED_LANGUAGES = [
('ar', ugettext_lazy('Arabic')),
('ca', ugettext_lazy('Catalan')),
('cs', ugettext_lazy('Czech')),
('de', ugettext_lazy('German')),
('el', ugettext_lazy('Greek')),
('en', ugettext_lazy('English')),
('es', ugettext_lazy('Spanish')),
('fi', ugettext_lazy('Finnish')),
('fr', ugettext_lazy('French')),
('gl', ugettext_lazy('Galician')),
('hu', ugettext_lazy('Hungarian')),
('id-id', ugettext_lazy('Indonesian')),
('is-is', ugettext_lazy('Icelandic')),
('it', ugettext_lazy('Italian')),
('jp', ugettext_lazy('Japanese')),
('ko', ugettext_lazy('Korean')),
('lt', ugettext_lazy('Lithuanian')),
('mn', ugettext_lazy('Mongolian')),
('nb', ugettext_lazy('Norwegian Bokmål')),
('nl-nl', ugettext_lazy('Netherlands Dutch')),
('fa', ugettext_lazy('Persian')),
('pl', ugettext_lazy('Polish')),
('pt-br', ugettext_lazy('Brazilian Portuguese')),
('pt-pt', ugettext_lazy('Portuguese')),
('ro', ugettext_lazy('Romanian')),
('ru', ugettext_lazy('Russian')),
('sv', ugettext_lazy('Swedish')),
('sk-sk', ugettext_lazy('Slovak')),
('th', ugettext_lazy('Thai')),
('uk', ugettext_lazy('Ukrainian')),
('zh-hans', ugettext_lazy('Chinese (Simplified)')),
('zh-hant', ugettext_lazy('Chinese (Traditional)')),
]
# Translatable strings to be made available to Javascript code
# as the wagtailConfig.STRINGS object
def get_js_translation_strings():
return {
'DELETE': _('Delete'),
'PAGE': _('Page'),
'PAGES': _('Pages'),
'LOADING': _('Loading…'),
'NO_RESULTS': _('No results'),
'SERVER_ERROR': _('Server Error'),
'SEE_ALL': _('See all'),
'CLOSE_EXPLORER': _('Close explorer'),
'ALT_TEXT': _('Alt text'),
'WRITE_HERE': _('Write here…'),
'HORIZONTAL_LINE': _('Horizontal line'),
'LINE_BREAK': _('Line break'),
'UNDO': _('Undo'),
'REDO': _('Redo'),
'RELOAD_PAGE': _('Reload the page'),
'RELOAD_EDITOR': _('Reload saved content'),
'SHOW_LATEST_CONTENT': _('Show latest content'),
'SHOW_ERROR': _('Show error'),
'EDITOR_CRASH': _('The editor just crashed. Content has been reset to the last saved version.'),
'BROKEN_LINK': _('Broken link'),
'MISSING_DOCUMENT': _('Missing document'),
'CLOSE': _('Close'),
'EDIT_PAGE': _('Edit \'{title}\''),
'VIEW_CHILD_PAGES_OF_PAGE': _('View child pages of \'{title}\''),
'PAGE_EXPLORER': _('Page explorer'),
'MONTHS': [
_('January'),
_('February'),
_('March'),
_('April'),
_('May'),
_('June'),
_('July'),
_('August'),
_('September'),
_('October'),
_('November'),
_('December')
],
'WEEKDAYS': [
_('Sunday'),
_('Monday'),
_('Tuesday'),
_('Wednesday'),
_('Thursday'),
_('Friday'),
_('Saturday')
],
'WEEKDAYS_SHORT': [
_('Sun'),
_('Mon'),
_('Tue'),
_('Wed'),
_('Thu'),
_('Fri'),
_('Sat')
]
}
def get_available_admin_languages():
return getattr(settings, 'WAGTAILADMIN_PERMITTED_LANGUAGES', WAGTAILADMIN_PROVIDED_LANGUAGES)
def get_available_admin_time_zones():
return getattr(settings, 'WAGTAIL_USER_TIME_ZONES', pytz.common_timezones)

114
wagtail/admin/mail.py Normal file
View file

@ -0,0 +1,114 @@
import logging
from django.conf import settings
from django.core.mail import get_connection
from django.core.mail.message import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.translation import override
from wagtail.admin.auth import users_with_page_permission
from wagtail.core.models import PageRevision
from wagtail.users.models import UserProfile
logger = logging.getLogger('wagtail.admin')
def send_mail(subject, message, recipient_list, from_email=None, **kwargs):
"""
Wrapper around Django's EmailMultiAlternatives as done in send_mail().
Custom from_email handling and special Auto-Submitted header.
"""
if not from_email:
if hasattr(settings, 'WAGTAILADMIN_NOTIFICATION_FROM_EMAIL'):
from_email = settings.WAGTAILADMIN_NOTIFICATION_FROM_EMAIL
elif hasattr(settings, 'DEFAULT_FROM_EMAIL'):
from_email = settings.DEFAULT_FROM_EMAIL
else:
from_email = 'webmaster@localhost'
connection = kwargs.get('connection', False) or get_connection(
username=kwargs.get('auth_user', None),
password=kwargs.get('auth_password', None),
fail_silently=kwargs.get('fail_silently', None),
)
multi_alt_kwargs = {
'connection': connection,
'headers': {
'Auto-Submitted': 'auto-generated',
}
}
mail = EmailMultiAlternatives(subject, message, from_email, recipient_list, **multi_alt_kwargs)
html_message = kwargs.get('html_message', None)
if html_message:
mail.attach_alternative(html_message, 'text/html')
return mail.send()
def send_notification(page_revision_id, notification, excluded_user_id):
# Get revision
revision = PageRevision.objects.get(id=page_revision_id)
# Get list of recipients
if notification == 'submitted':
# Get list of publishers
include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True)
recipients = users_with_page_permission(revision.page, 'publish', include_superusers)
elif notification in ['rejected', 'approved']:
# Get submitter
recipients = [revision.user]
else:
return False
# Get list of email addresses
email_recipients = [
recipient for recipient in recipients
if recipient.email and recipient.pk != excluded_user_id and getattr(
UserProfile.get_for_user(recipient),
notification + '_notifications'
)
]
# Return if there are no email addresses
if not email_recipients:
return True
# Get template
template_subject = 'wagtailadmin/notifications/' + notification + '_subject.txt'
template_text = 'wagtailadmin/notifications/' + notification + '.txt'
template_html = 'wagtailadmin/notifications/' + notification + '.html'
# Common context to template
context = {
"revision": revision,
"settings": settings,
}
# Send emails
sent_count = 0
for recipient in email_recipients:
try:
# update context with this recipient
context["user"] = recipient
# Translate text to the recipient language settings
with override(recipient.wagtail_userprofile.get_preferred_language()):
# Get email subject and content
email_subject = render_to_string(template_subject, context).strip()
email_content = render_to_string(template_text, context).strip()
kwargs = {}
if getattr(settings, 'WAGTAILADMIN_NOTIFICATION_USE_HTML', False):
kwargs['html_message'] = render_to_string(template_html, context)
# Send email
send_mail(email_subject, email_content, [recipient.email], **kwargs)
sent_count += 1
except Exception:
logger.exception(
"Failed to send notification email '%s' to %s",
email_subject, recipient.email
)
return sent_count == len(email_recipients)

View file

@ -1,5 +1,55 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from modelcluster.fields import ParentalKey
from taggit.models import Tag
# The edit_handlers module extends Page with some additional attributes required by
# wagtailadmin (namely, base_form_class and get_edit_handler). Importing this within
# wagtailadmin.models ensures that this happens in advance of running wagtailadmin's
# wagtail admin (namely, base_form_class and get_edit_handler). Importing this within
# wagtail.admin.models ensures that this happens in advance of running wagtail.admin's
# system checks.
from wagtail.admin import edit_handlers # NOQA
from wagtail.core.models import Page
def get_object_usage(obj):
"Returns a queryset of pages that link to a particular object"
pages = Page.objects.none()
# get all the relation objects for obj
relations = [f for f in type(obj)._meta.get_fields(include_hidden=True)
if (f.one_to_many or f.one_to_one) and f.auto_created]
for relation in relations:
related_model = relation.related_model
# if the relation is between obj and a page, get the page
if issubclass(related_model, Page):
pages |= Page.objects.filter(
id__in=related_model._base_manager.filter(**{
relation.field.name: obj.id
}).values_list('id', flat=True)
)
else:
# if the relation is between obj and an object that has a page as a
# property, return the page
for f in related_model._meta.fields:
if isinstance(f, ParentalKey) and issubclass(f.remote_field.model, Page):
pages |= Page.objects.filter(
id__in=related_model._base_manager.filter(
**{
relation.field.name: obj.id
}).values_list(f.attname, flat=True)
)
return pages
def popular_tags_for_model(model, count=10):
"""Return a queryset of the most frequently used tags used on this model class"""
content_type = ContentType.objects.get_for_model(model)
return Tag.objects.filter(
taggit_taggeditem_items__content_type=content_type
).annotate(
item_count=Count('taggit_taggeditem_items')
).order_by('-item_count')[:count]

View file

@ -1,3 +1,5 @@
from django.conf import settings
from wagtail.core.models import Page
@ -26,3 +28,19 @@ def get_explorable_root_page(user):
root_page = None
return root_page
def get_site_for_user(user):
root_page = get_explorable_root_page(user)
if root_page:
root_site = root_page.get_site()
else:
root_site = None
real_site_name = None
if root_site:
real_site_name = root_site.site_name if root_site.site_name else root_site.hostname
return {
'root_page': root_page,
'root_site': root_site,
'site_name': real_site_name if real_site_name else settings.WAGTAIL_SITE_NAME,
}

View file

@ -315,7 +315,8 @@ class HtmlToContentStateHandler(HTMLParser):
element_handler.handle_endtag(name, self.state, self.contentstate)
def handle_data(self, content):
# normalise whitespace sequences to a single space
# normalise whitespace sequences to a single space unless whitespace is contained in <pre> tag,
# in which case, leave it alone
# This is in line with https://www.w3.org/TR/html4/struct/text.html#h-9.1
content = re.sub(WHITESPACE_RE, ' ', content)
@ -341,7 +342,6 @@ class HtmlToContentStateHandler(HTMLParser):
content = content.lstrip()
elif self.state.leading_whitespace == FORCE_WHITESPACE and not content.startswith(' '):
content = ' ' + content
if content.endswith(' '):
# don't output trailing whitespace yet, because we want to discard it if the end
# of the block follows. Instead, we'll set leading_whitespace = force so that

View file

@ -1,6 +1,7 @@
from django.template.loader import render_to_string
from wagtail.admin.utils import get_site_for_user, user_has_any_page_permission
from wagtail.admin.auth import user_has_any_page_permission
from wagtail.admin.navigation import get_site_for_user
from wagtail.core import hooks
from wagtail.core.models import Page, Site

View file

@ -1,11 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {
initExplorer,
Icon,
Portal,
initExplorer,
initFocusOutline,
initSubmenus
initSubmenus,
initUpgradeNotification,
} from 'wagtail-client';
if (process.env.NODE_ENV === 'development') {
@ -35,4 +36,5 @@ document.addEventListener('DOMContentLoaded', () => {
initFocusOutline();
initSubmenus();
initUpgradeNotification();
});

View file

@ -38,7 +38,8 @@
urlParams = {
'allow_external_link': true,
'allow_email_link': true,
'allow_phone_link': true
'allow_phone_link': true,
'allow_anchor_link': true,
};
enclosingLink = getEnclosingLink();
@ -61,6 +62,10 @@
url = window.chooserUrls.phoneLinkChooser;
href = href.replace('tel:', '');
urlParams['link_url'] = href;
} else if (href.startsWith('#')) {
url = window.chooserUrls.anchorLinkChooser;
href = href.replace('#', '');
urlParams['link_url'] = href;
} else if (!linkType) { /* external link */
url = window.chooserUrls.externalLinkChooser;
urlParams['link_url'] = href;

View file

@ -110,6 +110,18 @@ PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
*/
$('#id_q', modal.body).trigger('focus');
},
'anchor_link': function(modal, jsonData) {
$('p.link-types a', modal.body).on('click', function() {
modal.loadUrl(this.href);
return false;
});
$('form', modal.body).on('submit', function() {
modal.postForm(this.action, $(this).serialize());
return false;
});
},
'email_link': function(modal, jsonData) {
$('p.link-types a', modal.body).on('click', function() {
modal.loadUrl(this.href);
@ -146,5 +158,5 @@ PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
'external_link_chosen': function(modal, jsonData) {
modal.respond('pageChosen', jsonData['result']);
modal.close();
}
},
};

View file

@ -1,48 +0,0 @@
$(function() {
'use strict';
/*
* Expected JSON payload:
* {
* "version" : "1.2.3", // Version number. Can only contain numbers and decimal point.
* "url" : "https://wagtail.io" // Absolute URL to page/file containing release notes or actual package. It's up to you.
* }
*/
function cmpVersion(a, b) {
var i;
var cmp;
var len;
var re = /(\.0)+[^\.]*$/;
a = (a + '').replace(re, '').split('.');
b = (b + '').replace(re, '').split('.');
len = Math.min(a.length, b.length);
for (i = 0; i < len; i++) {
cmp = parseInt(a[i], 10) - parseInt(b[i], 10);
if (cmp !== 0) {
return cmp;
}
}
return a.length - b.length;
}
function gtVersion(a, b) {
return cmpVersion(a, b) > 0;
}
var releasesUrl = 'https://releases.wagtail.io/latest.txt';
var currentVersion = window.wagtailVersion;
$.getJSON(releasesUrl, function(data) {
try {
if (data.version && gtVersion(data.version, currentVersion)) {
var $container = $('.panel-upgrade-notification')
$('.newversion', $container).text(data.version);
$('.releasenotes-link', $container).attr('href', data.url);
$container.show();
}
} catch (e) {}
});
});

View file

@ -0,0 +1,20 @@
{% extends "wagtailadmin/base.html" %}
{% load i18n %}
{% block titletag %}{% trans "Change name" %}{% endblock %}
{% block content %}
{% trans "Change name" as change_str %}
{% include "wagtailadmin/shared/header.html" with title=change_str %}
<div class="nice-padding">
<form action="{% url 'wagtailadmin_account_change_name' %}" method="POST" novalidate>
{% csrf_token %}
<ul class="fields">
{% for field in form %}
{% include "wagtailadmin/shared/field_as_li.html" with field=field %}
{% endfor %}
</ul>
<input type="submit" value="{% trans 'Change name' %}" class="button" />
</form>
</div>
{% endblock %}

View file

@ -27,66 +27,7 @@
EXTRA_CHILDREN_PARAMETERS: '',
};
wagtailConfig.STRINGS = {
DELETE: "{% trans 'Delete' %}",
PAGE: "{% trans 'Page' %}",
PAGES: "{% trans 'Pages' %}",
LOADING: "{% trans 'Loading…' %}",
NO_RESULTS: "{% trans 'No results' %}",
SERVER_ERROR: "{% trans 'Server Error' %}",
SEE_ALL: "{% trans 'See all' %}",
CLOSE_EXPLORER: "{% trans 'Close explorer' %}",
ALT_TEXT: "{% trans 'Alt text' %}",
WRITE_HERE: "{% trans 'Write here…' %}",
HORIZONTAL_LINE: "{% trans 'Horizontal line' %}",
LINE_BREAK: "{% trans 'Line break' %}",
UNDO: "{% trans 'Undo' %}",
REDO: "{% trans 'Redo' %}",
RELOAD_PAGE: "{% trans 'Reload the page' %}",
RELOAD_EDITOR: "{% trans 'Reload saved content' %}",
SHOW_LATEST_CONTENT: "{% trans 'Show latest content' %}",
SHOW_ERROR: "{% trans 'Show error' %}",
EDITOR_CRASH: "{% trans 'The editor just crashed. Content has been reset to the last saved version.' %}",
BROKEN_LINK: "{% trans 'Broken link' %}",
MISSING_DOCUMENT: "{% trans 'Missing document' %}",
CLOSE: "{% trans 'Close' %}",
EDIT_PAGE: "{% trans 'Edit \'{title}\'' %}",
VIEW_CHILD_PAGES_OF_PAGE: "{% trans 'View child pages of \'{title}\'' %}",
PAGE_EXPLORER: "{% trans 'Page explorer' %}",
MONTHS: [
"{% trans 'January' %}",
"{% trans 'February' %}",
"{% trans 'March' %}",
"{% trans 'April' %}",
"{% trans 'May' %}",
"{% trans 'June' %}",
"{% trans 'July' %}",
"{% trans 'August' %}",
"{% trans 'September' %}",
"{% trans 'October' %}",
"{% trans 'November' %}",
"{% trans 'December' %}"
],
WEEKDAYS: [
"{% trans 'Sunday' %}",
"{% trans 'Monday' %}",
"{% trans 'Tuesday' %}",
"{% trans 'Wednesday' %}",
"{% trans 'Thursday' %}",
"{% trans 'Friday' %}",
"{% trans 'Saturday' %}"
],
WEEKDAYS_SHORT: [
"{% trans 'Sun' %}",
"{% trans 'Mon' %}",
"{% trans 'Tue' %}",
"{% trans 'Wed' %}",
"{% trans 'Thu' %}",
"{% trans 'Fri' %}",
"{% trans 'Sat' %}"
]
};
wagtailConfig.STRINGS = {% js_translation_strings %};
wagtailConfig.ADMIN_URLS = {
PAGES: '{% url "wagtailadmin_explore_root" %}'

View file

@ -1,5 +1,5 @@
{% load i18n wagtailadmin_tags %}
{% if allow_external_link or allow_email_link or allow_phone_link or current == 'external' or current == 'email' or current == 'phone' %}
{% if allow_external_link or allow_email_link or allow_phone_link or allow_anchor_link or current == 'external' or current == 'email' or current == 'phone' or current == 'anchor' %}
<p class="link-types">
{% if current == 'internal' %}
<b>{% trans "Internal link" %}</b>
@ -24,9 +24,15 @@
{% endif %}
{% if current == 'phone' %}
| <b>{% trans "Phone link" %}</b>
| <b>{% trans "Phone link" %}</b>
{% elif allow_phone_link %}
| <a href="{% url 'wagtailadmin_choose_page_phone_link' %}{% querystring p=None parent_page_id=parent_page_id %}">{% trans "Phone link" %}</a>
| <a href="{% url 'wagtailadmin_choose_page_phone_link' %}{% querystring p=None parent_page_id=parent_page_id %}">{% trans "Phone link" %}</a>
{% endif %}
{% if current == 'anchor' %}
| <b>{% trans "Anchor link" %}</b>
{% elif allow_anchor_link %}
| <a href="{% url 'wagtailadmin_choose_page_anchor_link' %}{% querystring p=None parent_page_id=parent_page_id %}">{% trans "Anchor link" %}</a>
{% endif %}
</p>
{% endif %}

View file

@ -0,0 +1,17 @@
{% load i18n wagtailadmin_tags %}
{% trans "Add an anchor link" as anchor_str %}
{% include "wagtailadmin/shared/header.html" with title=anchor_str %}
<div class="nice-padding">
{% include 'wagtailadmin/chooser/_link_types.html' with current='anchor' %}
<form action="{% url 'wagtailadmin_choose_page_anchor_link' %}{% querystring %}" method="post" novalidate>
{% csrf_token %}
<ul class="fields">
{% for field in form %}
{% include "wagtailadmin/shared/field_as_li.html" %}
{% endfor %}
<li><input type="submit" value="{% trans 'Insert anchor' %}" class="button" /></li>
</ul>
</form>
</div>

View file

@ -1,8 +1,5 @@
{% load wagtailcore_tags static %}
<div class="panel nice-padding panel-upgrade-notification" style="display:none">
<div class="help-block help-warning">Wagtail upgrade available. Your version: <strong>{% wagtail_version %}</strong>. New version: <strong class="newversion"></strong>. <a class="releasenotes-link" href="">Read the release notes.</a></div>
<script>window.wagtailVersion = "{% wagtail_version %}";</script>
<script src="{% static 'wagtailadmin/js/upgrade_notify.js' %}" async="true"></script>
<div data-upgrade data-wagtail-version="{% wagtail_version %}" class="panel nice-padding panel-upgrade-notification" style="display:none">
<div class="help-block help-warning">Wagtail upgrade available. Your version: <strong>{% wagtail_version %}</strong>. New version: <strong data-upgrade-version></strong>. <a data-upgrade-link href="">Read the release notes.</a></div>
</div>

View file

@ -11,6 +11,7 @@
'externalLinkChooser': '{% url "wagtailadmin_choose_page_external_link" %}',
'emailLinkChooser': '{% url "wagtailadmin_choose_page_email_link" %}',
'phoneLinkChooser': '{% url "wagtailadmin_choose_page_phone_link" %}',
'anchorLinkChooser': '{% url "wagtailadmin_choose_page_anchor_link" %}',
};
window.unicodeSlugsEnabled = {% if unicode_slugs_enabled %}true{% else %}false{% endif %};
</script>

View file

@ -8,7 +8,7 @@ Expects a variable 'page', the page instance.
<div class="title-wrapper">
{% if page.can_choose %}
<a class="choose-page" href="#{{ page.id|unlocalize }}" data-id="{{ page.id|unlocalize }}" data-title="{{ page.get_admin_display_title }}" data-url="{{ page.url }}" data-parent-id="{{ page.get_parent.id|unlocalize }}" data-edit-url="{% url 'wagtailadmin_pages:edit' page.id %}">{{ page.get_admin_display_title }}</a>
<a class="choose-page" href="#{{ page.id|unlocalize }}" data-id="{{ page.id|unlocalize }}" data-title="{{ page.title }}" data-url="{{ page.url }}" data-parent-id="{{ page.get_parent.id|unlocalize }}" data-edit-url="{% url 'wagtailadmin_pages:edit' page.id %}">{{ page.get_admin_display_title }}</a>
{% else %}
{{ page.get_admin_display_title }}
{% endif %}

View file

@ -1,4 +1,5 @@
import itertools
import json
from django import template
from django.conf import settings
@ -12,6 +13,7 @@ from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from wagtail.admin.locale import get_js_translation_strings
from wagtail.admin.menu import admin_menu
from wagtail.admin.navigation import get_explorable_root_page
from wagtail.admin.search import admin_search_areas
@ -473,3 +475,8 @@ def avatar_url(user, size=50):
return gravatar_url
return static('wagtailadmin/images/default-user-avatar.png')
@register.simple_tag
def js_translation_strings():
return mark_safe(json.dumps(get_js_translation_strings()))

View file

@ -3,6 +3,7 @@ import tempfile
import pytz
from django import VERSION as DJANGO_VERSION
from django.contrib.auth import views as auth_views
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
@ -11,7 +12,7 @@ from django.core import mail
from django.test import TestCase, override_settings
from django.urls import reverse
from wagtail.admin.utils import (
from wagtail.admin.locale import (
WAGTAILADMIN_PROVIDED_LANGUAGES, get_available_admin_languages, get_available_admin_time_zones)
from wagtail.tests.utils import WagtailTestUtils
from wagtail.users.models import UserProfile
@ -309,7 +310,10 @@ class TestAccountSection(TestCase, WagtailTestUtils):
# Check that a validation error was raised
self.assertTrue('new_password2' in response.context['form'].errors.keys())
self.assertTrue("The two password fields didn't match." in response.context['form'].errors['new_password2'])
if DJANGO_VERSION >= (3, 0):
self.assertTrue("The two password fields didnt match." in response.context['form'].errors['new_password2'])
else:
self.assertTrue("The two password fields didn't match." in response.context['form'].errors['new_password2'])
# Check that the password was not changed
self.assertTrue(get_user_model().objects.get(pk=self.user.pk).check_password('password'))
@ -407,6 +411,31 @@ class TestAccountSection(TestCase, WagtailTestUtils):
# Check that the current language is assumed as English
self.assertEqual(profile.get_preferred_language(), "en")
def test_change_name(self):
"""
This tests that the change name view responds with a change name page
"""
# Get change name page
response = self.client.get(reverse('wagtailadmin_account_change_name'))
# Check that the user received a change name page
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailadmin/account/change_name.html')
def test_change_name_post(self):
post_data = {
'first_name': 'Fox',
'last_name': 'Mulder',
}
response = self.client.post(reverse('wagtailadmin_account_change_name'), post_data)
# Check that the user was redirected to the account page
self.assertRedirects(response, reverse('wagtailadmin_account'))
# Check that the name was changed
self.assertEqual(get_user_model().objects.get(pk=self.user.pk).first_name, post_data['first_name'])
self.assertEqual(get_user_model().objects.get(pk=self.user.pk).last_name, post_data['last_name'])
@override_settings(WAGTAILADMIN_PERMITTED_LANGUAGES=[('en', 'English'), ('es', 'Spanish')])
def test_available_admin_languages_with_permitted_languages(self):
self.assertListEqual(get_available_admin_languages(), [('en', 'English'), ('es', 'Spanish')])
@ -692,7 +721,12 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
self.password_reset_uid = force_text(urlsafe_base64_encode(force_bytes(self.user.pk)))
# Create url_args
self.url_kwargs = dict(uidb64=self.password_reset_uid, token=auth_views.INTERNAL_RESET_URL_TOKEN)
if DJANGO_VERSION >= (3, 0):
token = auth_views.PasswordResetConfirmView.reset_url_token
else:
token = auth_views.INTERNAL_RESET_URL_TOKEN
self.url_kwargs = dict(uidb64=self.password_reset_uid, token=token)
# Add token to session object
s = self.client.session
@ -772,7 +806,11 @@ class TestPasswordReset(TestCase, WagtailTestUtils):
# Check that a validation error was raised
self.assertTrue('new_password2' in response.context['form'].errors.keys())
self.assertTrue("The two password fields didn't match." in response.context['form'].errors['new_password2'])
if DJANGO_VERSION >= (3, 0):
self.assertTrue("The two password fields didnt match." in response.context['form'].errors['new_password2'])
else:
self.assertTrue("The two password fields didn't match." in response.context['form'].errors['new_password2'])
# Check that the password was not changed
self.assertTrue(get_user_model().objects.get(username='test').check_password('password'))

View file

@ -6,7 +6,7 @@ from django.template import Context, Template
from django.test import RequestFactory, TestCase
from django.urls import reverse
from wagtail.admin.utils import user_has_any_page_permission
from wagtail.admin.auth import user_has_any_page_permission
from wagtail.core.models import Site
from wagtail.tests.utils import WagtailTestUtils

View file

@ -208,7 +208,13 @@ class TestChooserBrowseChild(TestCase, WagtailTestUtils):
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailadmin/chooser/browse.html')
self.assertInHTML("foobarbaz (simple page)", response.json().get('html'))
html = response.json().get('html')
self.assertInHTML("foobarbaz (simple page)", html)
# The data-title attribute should not use the custom admin display title,
# because JS code that uses that attribute (e.g. the rich text editor)
# should use the real page title.
self.assertIn('data-title="foobarbaz"', html)
def test_parent_with_admin_display_title(self):
# Add another child under child_page so it renders a chooser list
@ -566,6 +572,66 @@ class TestChooserExternalLink(TestCase, WagtailTestUtils):
self.assertEqual(response_json['result']['title'], "admin")
class TestChooserAnchorLink(TestCase, WagtailTestUtils):
def setUp(self):
self.login()
def get(self, params={}):
return self.client.get(reverse('wagtailadmin_choose_page_anchor_link'), params)
def post(self, post_data={}, url_params={}):
url = reverse('wagtailadmin_choose_page_anchor_link')
if url_params:
url += '?' + urlencode(url_params)
return self.client.post(url, post_data)
def test_simple(self):
response = self.get()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailadmin/chooser/anchor_link.html')
def test_prepopulated_form(self):
response = self.get({'link_text': 'Example Anchor Text', 'link_url': 'exampleanchor'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Example Anchor Text')
self.assertContains(response, 'exampleanchor')
def test_create_link(self):
response = self.post({'anchor-link-chooser-url': 'exampleanchor', 'anchor-link-chooser-link_text': 'Example Anchor Text'})
result = json.loads(response.content.decode())['result']
self.assertEqual(result['url'], "#exampleanchor")
self.assertEqual(result['title'], "Example Anchor Text") # When link text is given, it is used
self.assertEqual(result['prefer_this_title_as_link_text'], True)
def test_create_link_without_text(self):
response = self.post({'anchor-link-chooser-url': 'exampleanchor'})
result = json.loads(response.content.decode())['result']
self.assertEqual(result['url'], "#exampleanchor")
self.assertEqual(result['title'], "exampleanchor") # When no link text is given, it uses anchor
self.assertEqual(result['prefer_this_title_as_link_text'], False)
def test_notice_changes_to_link_text(self):
response = self.post(
{'anchor-link-chooser-url': 'exampleanchor2', 'email-link-chooser-link_text': 'Example Text'}, # POST data
{'link_url': 'exampleanchor2', 'link_text': 'Example Text'} # GET params - initial data
)
result = json.loads(response.content.decode())['result']
self.assertEqual(result['url'], "#exampleanchor2")
self.assertEqual(result['title'], "exampleanchor2")
# no change to link text, so prefer the existing link/selection content where available
self.assertEqual(result['prefer_this_title_as_link_text'], True)
response = self.post(
{'anchor-link-chooser-url': 'exampleanchor2', 'anchor-link-chooser-link_text': 'Example Anchor Test 2.1'}, # POST data
{'link_url': 'exampleanchor', 'link_text': 'Example Anchor Text'} # GET params - initial data
)
result = json.loads(response.content.decode())['result']
self.assertEqual(result['url'], "#exampleanchor2")
self.assertEqual(result['title'], "Example Anchor Test 2.1")
# link text has changed, so tell the caller to use it
self.assertEqual(result['prefer_this_title_as_link_text'], True)
class TestChooserEmailLink(TestCase, WagtailTestUtils):
def setUp(self):
self.login()

View file

@ -4,6 +4,7 @@ import os
from itertools import chain
from unittest import mock
from django import VERSION as DJANGO_VERSION
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
@ -1916,7 +1917,7 @@ class TestPageEdit(TestCase, WagtailTestUtils):
# Check the HTML response
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'tests/simple_page.html')
self.assertContains(response, "I&#39;ve been edited!")
self.assertContains(response, "I&#39;ve been edited!", html=True)
def test_preview_on_edit_no_session_key(self):
preview_url = reverse('wagtailadmin_pages:preview_on_edit',
@ -3002,9 +3003,14 @@ class TestPageCopy(TestCase, WagtailTestUtils):
self.assertEqual(response.status_code, 200)
# Check that a form error was raised
self.assertFormError(
response, 'form', 'new_slug', "Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."
)
if DJANGO_VERSION >= (3, 0):
self.assertFormError(
response, 'form', 'new_slug', "Enter a valid “slug” consisting of letters, numbers, underscores or hyphens."
)
else:
self.assertFormError(
response, 'form', 'new_slug', "Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."
)
def test_page_copy_no_publish_permission(self):
# Turn user into an editor who can add pages but not publish them
@ -4229,16 +4235,52 @@ class TestRevisions(TestCase, WagtailTestUtils):
self.assertContains(response, this_christmas_preview_url)
self.assertContains(response, this_christmas_revert_url)
def test_preview_revision(self):
def request_preview_revision(self):
last_christmas_preview_url = reverse(
'wagtailadmin_pages:revisions_view',
args=(self.christmas_event.id, self.last_christmas_revision.id)
)
response = self.client.get(last_christmas_preview_url)
self.assertEqual(response.status_code, 200)
return self.client.get(last_christmas_preview_url)
def test_preview_revision(self):
response = self.request_preview_revision()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Last Christmas I gave you my heart")
def test_preview_revision_with_no_page_permissions_redirects_to_admin(self):
admin_only_user = get_user_model().objects.create_user(
username='admin_only',
email='admin_only@email.com',
password='password'
)
admin_only_user.user_permissions.add(
Permission.objects.get_by_natural_key(
codename='access_admin',
app_label='wagtailadmin',
model='admin'
)
)
self.login(user=admin_only_user)
response = self.request_preview_revision()
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse('wagtailadmin_home'))
def test_preview_revision_forbidden_without_permission(self):
# Alter the editors group so it has no permissions for Christmas page.
st_patricks = Page.objects.get(slug='saint-patrick')
editors_group = Group.objects.get(name='Site-wide editors')
editors_group.page_permissions.update(page_id=st_patricks.id)
editor = get_user_model().objects.get(username='siteeditor')
self.login(editor)
response = self.request_preview_revision()
self.assertEqual(response.status_code, 403)
def test_revert_revision(self):
last_christmas_preview_url = reverse(
'wagtailadmin_pages:revisions_revert',
@ -4318,7 +4360,11 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
response = self.client.get(compare_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>')
self.assertContains(
response,
'<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>',
html=True
)
def test_compare_revisions_earliest(self):
compare_url = reverse(
@ -4328,7 +4374,11 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
response = self.client.get(compare_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>')
self.assertContains(
response,
'<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>',
html=True
)
def test_compare_revisions_latest(self):
compare_url = reverse(
@ -4338,7 +4388,11 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
response = self.client.get(compare_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>')
self.assertContains(
response,
'<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll give it to someone special</span>',
html=True
)
def test_compare_revisions_live(self):
# Mess with the live version, bypassing revisions
@ -4355,7 +4409,11 @@ class TestCompareRevisions(TestCase, WagtailTestUtils):
response = self.client.get(compare_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll just feed it to the dog</span>')
self.assertContains(
response,
'<span class="deletion">Last Christmas I gave you my heart, but the very next day you gave it away</span><span class="addition">This year, to save me from tears, I&#39;ll just feed it to the dog</span>',
html=True
)
class TestCompareRevisionsWithNonModelField(TestCase, WagtailTestUtils):
@ -5275,6 +5333,18 @@ class TestDraftAccess(TestCase, WagtailTestUtils):
# User can view
self.assertEqual(response.status_code, 200)
def test_middleware_response_is_returned(self):
"""
If middleware returns a response while serving a page preview, that response should be
returned back to the user
"""
self.login()
response = self.client.get(
reverse('wagtailadmin_pages:view_draft', args=(self.child_page.id, )),
HTTP_USER_AGENT='EvilHacker'
)
self.assertEqual(response.status_code, 403)
class TestPreview(TestCase, WagtailTestUtils):
fixtures = ['test.json']

View file

@ -10,8 +10,9 @@ from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext_lazy as _
from taggit.models import Tag
from wagtail.admin.auth import user_has_any_page_permission
from wagtail.admin.mail import send_mail
from wagtail.admin.menu import MenuItem
from wagtail.admin.utils import send_mail, user_has_any_page_permission
from wagtail.core.models import Page
from wagtail.tests.utils import WagtailTestUtils

View file

@ -6,6 +6,7 @@ from django.views.generic import TemplateView
from django.http import Http404
from django.views.defaults import page_not_found
from wagtail.admin.auth import require_admin_access
from wagtail.admin.urls import pages as wagtailadmin_pages_urls
from wagtail.admin.urls import collections as wagtailadmin_collections_urls
from wagtail.admin.urls import password_reset as wagtailadmin_password_reset_urls
@ -13,7 +14,6 @@ from wagtail.admin.views import account, chooser, home, pages, tags, userbar
from wagtail.admin.api import urls as api_urls
from wagtail.core import hooks
from wagtail.utils.urlpatterns import decorate_urlpatterns
from wagtail.admin.decorators import require_admin_access
urlpatterns = [
@ -38,6 +38,7 @@ urlpatterns = [
url(r'^choose-external-link/$', chooser.external_link, name='wagtailadmin_choose_page_external_link'),
url(r'^choose-email-link/$', chooser.email_link, name='wagtailadmin_choose_page_email_link'),
url(r'^choose-phone-link/$', chooser.phone_link, name='wagtailadmin_choose_page_phone_link'),
url(r'^choose-anchor-link/$', chooser.anchor_link, name='wagtailadmin_choose_page_anchor_link'),
url(r'^tag-autocomplete/$', tags.autocomplete, name='wagtailadmin_tag_autocomplete'),
@ -46,6 +47,7 @@ urlpatterns = [
url(r'^account/$', account.account, name='wagtailadmin_account'),
url(r'^account/change_password/$', account.change_password, name='wagtailadmin_account_change_password'),
url(r'^account/change_email/$', account.change_email, name='wagtailadmin_account_change_email'),
url(r'^account/change_name/$', account.change_name, name='wagtailadmin_account_change_name'),
url(
r'^account/notification_preferences/$',
account.notification_preferences,

View file

@ -1,351 +1,29 @@
# -*- coding: utf-8 -*-
import logging
from functools import wraps
import pytz
import sys
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.core.mail import get_connection
from django.core.mail.message import EmailMultiAlternatives
from django.db.models import Count, Q
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
from django.utils.translation import override, ugettext_lazy
from modelcluster.fields import ParentalKey
from taggit.models import Tag
from wagtail.utils.deprecation import MovedDefinitionHandler, RemovedInWagtail29Warning
from wagtail.admin.navigation import get_explorable_root_page
from wagtail.core.models import GroupPagePermission, Page, PageRevision
from wagtail.users.models import UserProfile
logger = logging.getLogger('wagtail.admin')
MOVED_DEFINITIONS = {
'WAGTAILADMIN_PROVIDED_LANGUAGES': 'wagtail.admin.locale',
'get_js_translation_strings': 'wagtail.admin.locale',
'get_available_admin_languages': 'wagtail.admin.locale',
'get_available_admin_time_zones': 'wagtail.admin.locale',
# Wagtail languages with >=90% coverage
# This list is manually maintained
WAGTAILADMIN_PROVIDED_LANGUAGES = [
('ar', ugettext_lazy('Arabic')),
('ca', ugettext_lazy('Catalan')),
('cs', ugettext_lazy('Czech')),
('de', ugettext_lazy('German')),
('el', ugettext_lazy('Greek')),
('en', ugettext_lazy('English')),
('es', ugettext_lazy('Spanish')),
('fi', ugettext_lazy('Finnish')),
('fr', ugettext_lazy('French')),
('gl', ugettext_lazy('Galician')),
('hu', ugettext_lazy('Hungarian')),
('id-id', ugettext_lazy('Indonesian')),
('is-is', ugettext_lazy('Icelandic')),
('it', ugettext_lazy('Italian')),
('jp', ugettext_lazy('Japanese')),
('ko', ugettext_lazy('Korean')),
('lt', ugettext_lazy('Lithuanian')),
('mn', ugettext_lazy('Mongolian')),
('nb', ugettext_lazy('Norwegian Bokmål')),
('nl-nl', ugettext_lazy('Netherlands Dutch')),
('fa', ugettext_lazy('Persian')),
('pl', ugettext_lazy('Polish')),
('pt-br', ugettext_lazy('Brazilian Portuguese')),
('pt-pt', ugettext_lazy('Portuguese')),
('ro', ugettext_lazy('Romanian')),
('ru', ugettext_lazy('Russian')),
('sv', ugettext_lazy('Swedish')),
('sk-sk', ugettext_lazy('Slovak')),
('th', ugettext_lazy('Thai')),
('uk', ugettext_lazy('Ukrainian')),
('zh-hans', ugettext_lazy('Chinese (Simplified)')),
('zh-hant', ugettext_lazy('Chinese (Traditional)')),
]
'get_object_usage': 'wagtail.admin.models',
'popular_tags_for_model': 'wagtail.admin.models',
'users_with_page_permission': 'wagtail.admin.auth',
'permission_denied': 'wagtail.admin.auth',
'user_passes_test': 'wagtail.admin.auth',
'permission_required': 'wagtail.admin.auth',
'any_permission_required': 'wagtail.admin.auth',
'PermissionPolicyChecker': 'wagtail.admin.auth',
'user_has_any_page_permission': 'wagtail.admin.auth',
def get_available_admin_languages():
return getattr(settings, 'WAGTAILADMIN_PERMITTED_LANGUAGES', WAGTAILADMIN_PROVIDED_LANGUAGES)
'send_mail': 'wagtail.admin.mail',
'send_notification': 'wagtail.admin.mail',
'get_site_for_user': 'wagtail.admin.navigation',
}
def get_available_admin_time_zones():
return getattr(settings, 'WAGTAIL_USER_TIME_ZONES', pytz.common_timezones)
def get_object_usage(obj):
"Returns a queryset of pages that link to a particular object"
pages = Page.objects.none()
# get all the relation objects for obj
relations = [f for f in type(obj)._meta.get_fields(include_hidden=True)
if (f.one_to_many or f.one_to_one) and f.auto_created]
for relation in relations:
related_model = relation.related_model
# if the relation is between obj and a page, get the page
if issubclass(related_model, Page):
pages |= Page.objects.filter(
id__in=related_model._base_manager.filter(**{
relation.field.name: obj.id
}).values_list('id', flat=True)
)
else:
# if the relation is between obj and an object that has a page as a
# property, return the page
for f in related_model._meta.fields:
if isinstance(f, ParentalKey) and issubclass(f.remote_field.model, Page):
pages |= Page.objects.filter(
id__in=related_model._base_manager.filter(
**{
relation.field.name: obj.id
}).values_list(f.attname, flat=True)
)
return pages
def popular_tags_for_model(model, count=10):
"""Return a queryset of the most frequently used tags used on this model class"""
content_type = ContentType.objects.get_for_model(model)
return Tag.objects.filter(
taggit_taggeditem_items__content_type=content_type
).annotate(
item_count=Count('taggit_taggeditem_items')
).order_by('-item_count')[:count]
def users_with_page_permission(page, permission_type, include_superusers=True):
# Get user model
User = get_user_model()
# Find GroupPagePermission records of the given type that apply to this page or an ancestor
ancestors_and_self = list(page.get_ancestors()) + [page]
perm = GroupPagePermission.objects.filter(permission_type=permission_type, page__in=ancestors_and_self)
q = Q(groups__page_permissions__in=perm)
# Include superusers
if include_superusers:
q |= Q(is_superuser=True)
return User.objects.filter(is_active=True).filter(q).distinct()
def permission_denied(request):
"""Return a standard 'permission denied' response"""
if request.is_ajax():
raise PermissionDenied
from wagtail.admin import messages
messages.error(request, _('Sorry, you do not have permission to access this area.'))
return redirect('wagtailadmin_home')
def user_passes_test(test):
"""
Given a test function that takes a user object and returns a boolean,
return a view decorator that denies access to the user if the test returns false.
"""
def decorator(view_func):
# decorator takes the view function, and returns the view wrapped in
# a permission check
@wraps(view_func)
def wrapped_view_func(request, *args, **kwargs):
if test(request.user):
# permission check succeeds; run the view function as normal
return view_func(request, *args, **kwargs)
else:
# permission check failed
return permission_denied(request)
return wrapped_view_func
return decorator
def permission_required(permission_name):
"""
Replacement for django.contrib.auth.decorators.permission_required which returns a
more meaningful 'permission denied' response than just redirecting to the login page.
(The latter doesn't work anyway because Wagtail doesn't define LOGIN_URL...)
"""
def test(user):
return user.has_perm(permission_name)
# user_passes_test constructs a decorator function specific to the above test function
return user_passes_test(test)
def any_permission_required(*perms):
"""
Decorator that accepts a list of permission names, and allows the user
to pass if they have *any* of the permissions in the list
"""
def test(user):
for perm in perms:
if user.has_perm(perm):
return True
return False
return user_passes_test(test)
class PermissionPolicyChecker:
"""
Provides a view decorator that enforces the given permission policy,
returning the wagtailadmin 'permission denied' response if permission not granted
"""
def __init__(self, policy):
self.policy = policy
def require(self, action):
def test(user):
return self.policy.user_has_permission(user, action)
return user_passes_test(test)
def require_any(self, *actions):
def test(user):
return self.policy.user_has_any_permission(user, actions)
return user_passes_test(test)
def send_mail(subject, message, recipient_list, from_email=None, **kwargs):
"""
Wrapper around Django's EmailMultiAlternatives as done in send_mail().
Custom from_email handling and special Auto-Submitted header.
"""
if not from_email:
if hasattr(settings, 'WAGTAILADMIN_NOTIFICATION_FROM_EMAIL'):
from_email = settings.WAGTAILADMIN_NOTIFICATION_FROM_EMAIL
elif hasattr(settings, 'DEFAULT_FROM_EMAIL'):
from_email = settings.DEFAULT_FROM_EMAIL
else:
from_email = 'webmaster@localhost'
connection = kwargs.get('connection', False) or get_connection(
username=kwargs.get('auth_user', None),
password=kwargs.get('auth_password', None),
fail_silently=kwargs.get('fail_silently', None),
)
multi_alt_kwargs = {
'connection': connection,
'headers': {
'Auto-Submitted': 'auto-generated',
}
}
mail = EmailMultiAlternatives(subject, message, from_email, recipient_list, **multi_alt_kwargs)
html_message = kwargs.get('html_message', None)
if html_message:
mail.attach_alternative(html_message, 'text/html')
return mail.send()
def send_notification(page_revision_id, notification, excluded_user_id):
# Get revision
revision = PageRevision.objects.get(id=page_revision_id)
# Get list of recipients
if notification == 'submitted':
# Get list of publishers
include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True)
recipients = users_with_page_permission(revision.page, 'publish', include_superusers)
elif notification in ['rejected', 'approved']:
# Get submitter
recipients = [revision.user]
else:
return False
# Get list of email addresses
email_recipients = [
recipient for recipient in recipients
if recipient.email and recipient.pk != excluded_user_id and getattr(
UserProfile.get_for_user(recipient),
notification + '_notifications'
)
]
# Return if there are no email addresses
if not email_recipients:
return True
# Get template
template_subject = 'wagtailadmin/notifications/' + notification + '_subject.txt'
template_text = 'wagtailadmin/notifications/' + notification + '.txt'
template_html = 'wagtailadmin/notifications/' + notification + '.html'
# Common context to template
context = {
"revision": revision,
"settings": settings,
}
# Send emails
sent_count = 0
for recipient in email_recipients:
try:
# update context with this recipient
context["user"] = recipient
# Translate text to the recipient language settings
with override(recipient.wagtail_userprofile.get_preferred_language()):
# Get email subject and content
email_subject = render_to_string(template_subject, context).strip()
email_content = render_to_string(template_text, context).strip()
kwargs = {}
if getattr(settings, 'WAGTAILADMIN_NOTIFICATION_USE_HTML', False):
kwargs['html_message'] = render_to_string(template_html, context)
# Send email
send_mail(email_subject, email_content, [recipient.email], **kwargs)
sent_count += 1
except Exception:
logger.exception(
"Failed to send notification email '%s' to %s",
email_subject, recipient.email
)
return sent_count == len(email_recipients)
def user_has_any_page_permission(user):
"""
Check if a user has any permission to add, edit, or otherwise manage any
page.
"""
# Can't do nothin if you're not active.
if not user.is_active:
return False
# Superusers can do anything.
if user.is_superuser:
return True
# At least one of the users groups has a GroupPagePermission.
# The user can probably do something.
if GroupPagePermission.objects.filter(group__in=user.groups.all()).exists():
return True
# Specific permissions for a page type do not mean anything.
# No luck! This user can not do anything with pages.
return False
def get_site_for_user(user):
root_page = get_explorable_root_page(user)
if root_page:
root_site = root_page.get_site()
else:
root_site = None
real_site_name = None
if root_site:
real_site_name = root_site.site_name if root_site.site_name else root_site.hostname
return {
'root_page': root_page,
'root_site': root_site,
'site_name': real_site_name if real_site_name else settings.WAGTAIL_SITE_NAME,
}
sys.modules[__name__] = MovedDefinitionHandler(sys.modules[__name__], MOVED_DEFINITIONS, RemovedInWagtail29Warning)

View file

@ -12,7 +12,7 @@ from django.utils.translation import activate
from wagtail.admin.forms.auth import LoginForm, PasswordResetForm
from wagtail.core import hooks
from wagtail.users.forms import (
AvatarPreferencesForm, CurrentTimeZoneForm, EmailForm, NotificationPreferencesForm, PreferredLanguageForm)
AvatarPreferencesForm, CurrentTimeZoneForm, EmailForm, NameForm, NotificationPreferencesForm, PreferredLanguageForm)
from wagtail.users.models import UserProfile
from wagtail.utils.loading import get_custom_form
@ -95,6 +95,22 @@ def change_email(request):
})
def change_name(request):
if request.method == 'POST':
form = NameForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Your name has been changed successfully!"))
return redirect('wagtailadmin_account')
else:
form = NameForm(instance=request.user)
return render(request, 'wagtailadmin/account/change_name.html', {
'form': form,
})
class PasswordResetEnabledViewMixin:
"""
Class based view mixin that disables the view if password reset is disabled by one of the following settings:

View file

@ -3,7 +3,8 @@ from django.http import Http404
from django.shortcuts import get_object_or_404, render
from wagtail.admin.forms.choosers import (
EmailLinkChooserForm, ExternalLinkChooserForm, PhoneLinkChooserForm)
AnchorLinkChooserForm, EmailLinkChooserForm, ExternalLinkChooserForm, PhoneLinkChooserForm)
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.core import hooks
@ -13,7 +14,7 @@ from wagtail.core.utils import resolve_model_string
def shared_context(request, extra_context=None):
context = {
# parent_page ID is passed as a GET parameter on the external_link and email_link views
# parent_page ID is passed as a GET parameter on the external_link, anchor_link and mail_link views
# so that it's remembered when browsing from 'Internal link' to another link type
# and back again. On the 'browse' / 'internal link' view this will be overridden to be
# sourced from the standard URL path parameter instead.
@ -21,6 +22,7 @@ def shared_context(request, extra_context=None):
'allow_external_link': request.GET.get('allow_external_link'),
'allow_email_link': request.GET.get('allow_email_link'),
'allow_phone_link': request.GET.get('allow_phone_link'),
'allow_anchor_link': request.GET.get('allow_anchor_link'),
}
if extra_context:
context.update(extra_context)
@ -224,6 +226,37 @@ def external_link(request):
)
def anchor_link(request):
initial_data = {
'link_text': request.GET.get('link_text', ''),
'url': request.GET.get('link_url', ''),
}
if request.method == 'POST':
form = AnchorLinkChooserForm(request.POST, initial=initial_data, prefix='anchor-link-chooser')
if form.is_valid():
result = {
'url': '#' + form.cleaned_data['url'],
'title': form.cleaned_data['link_text'].strip() or form.cleaned_data['url'],
'prefer_this_title_as_link_text': ('link_text' in form.changed_data),
}
return render_modal_workflow(
request, None, None,
None, json_data={'step': 'external_link_chosen', 'result': result}
)
else:
form = AnchorLinkChooserForm(initial=initial_data, prefix='anchor-link-chooser')
return render_modal_workflow(
request,
'wagtailadmin/chooser/anchor_link.html', None,
shared_context(request, {
'form': form,
}), json_data={'step': 'anchor_link'}
)
def email_link(request):
initial_data = {
'link_text': request.GET.get('link_text', ''),

View file

@ -7,7 +7,7 @@ from django.views.generic.edit import BaseCreateView, BaseDeleteView, BaseUpdate
from django.views.generic.list import BaseListView
from wagtail.admin import messages
from wagtail.admin.utils import permission_denied
from wagtail.admin.auth import permission_denied
class PermissionCheckedMixin:

View file

@ -7,8 +7,8 @@ from django.http import Http404
from django.shortcuts import render
from django.template.loader import render_to_string
from wagtail.admin.navigation import get_site_for_user
from wagtail.admin.site_summary import SiteSummaryPanel
from wagtail.admin.utils import get_site_for_user
from wagtail.core import hooks
from wagtail.core.models import Page, PageRevision, UserPagePermissionsProxy

View file

@ -20,10 +20,11 @@ from django.views.generic import View
from wagtail.admin import messages, signals
from wagtail.admin.action_menu import PageActionMenu
from wagtail.admin.auth import user_has_any_page_permission, user_passes_test
from wagtail.admin.forms.pages import CopyForm
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.mail import send_notification
from wagtail.admin.navigation import get_explorable_root_page
from wagtail.admin.utils import send_notification, user_has_any_page_permission, user_passes_test
from wagtail.core import hooks
from wagtail.core.models import Page, PageRevision, UserPagePermissionsProxy
from wagtail.search.query import MATCH_ALL
@ -586,7 +587,7 @@ def view_draft(request, page_id):
perms = page.permissions_for_user(request.user)
if not (perms.can_publish() or perms.can_edit()):
raise PermissionDenied
return page.serve_preview(page.dummy_request(request), page.default_preview_mode)
return page.make_preview_request(request, page.default_preview_mode)
class PreviewOnEdit(View):
@ -646,8 +647,7 @@ class PreviewOnEdit(View):
form.save(commit=False)
preview_mode = request.GET.get('mode', page.default_preview_mode)
return page.serve_preview(page.dummy_request(request),
preview_mode)
return page.make_preview_request(request, preview_mode)
class PreviewOnCreate(PreviewOnEdit):
@ -1055,11 +1055,9 @@ def preview_for_moderation(request, revision_id):
page = revision.as_page_object()
request.revision_id = revision_id
# pass in the real user request rather than page.dummy_request(), so that request.user
# and request.revision_id will be picked up by the wagtail user bar
return page.serve_preview(request, page.default_preview_mode)
return page.make_preview_request(request, page.default_preview_mode, extra_request_attrs={
'revision_id': revision_id
})
@require_POST
@ -1177,10 +1175,15 @@ def revisions_revert(request, page_id, revision_id):
@user_passes_test(user_has_any_page_permission)
def revisions_view(request, page_id, revision_id):
page = get_object_or_404(Page, id=page_id).specific
perms = page.permissions_for_user(request.user)
if not (perms.can_publish() or perms.can_edit()):
raise PermissionDenied
revision = get_object_or_404(page.revisions, id=revision_id)
revision_page = revision.as_page_object()
return revision_page.serve_preview(page.dummy_request(request), page.default_preview_mode)
return revision_page.make_preview_request(request, page.default_preview_mode)
def revisions_compare(request, page_id, revision_id_a, revision_id_b):

View file

@ -5,6 +5,8 @@ from django.utils.translation import ugettext
from draftjs_exporter.dom import DOM
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail.admin.auth import user_has_any_page_permission
from wagtail.admin.locale import get_available_admin_languages, get_available_admin_time_zones
from wagtail.admin.menu import MenuItem, SubmenuMenuItem, settings_menu
from wagtail.admin.navigation import get_explorable_root_page
from wagtail.admin.rich_text import (
@ -16,9 +18,6 @@ from wagtail.admin.rich_text.converters.html_to_contentstate import (
BlockElementHandler, ExternalLinkElementHandler, HorizontalRuleHandler,
InlineStyleElementHandler, ListElementHandler, ListItemElementHandler, PageLinkElementHandler)
from wagtail.admin.search import SearchArea
from wagtail.admin.utils import (
get_available_admin_languages, get_available_admin_time_zones,
user_has_any_page_permission)
from wagtail.admin.views.account import password_management_enabled
from wagtail.admin.viewsets import viewsets
from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook, PageListingButton
@ -203,7 +202,7 @@ def register_account_set_profile_picture(request):
return {
'url': reverse('wagtailadmin_account_change_avatar'),
'label': _('Set profile picture'),
'help_text': _("Change your profile picture")
'help_text': _("Change your profile picture.")
}
@ -257,6 +256,15 @@ def register_account_current_time_zone(request):
}
@hooks.register('register_account_menu_item')
def register_account_change_name(request):
return {
'url': reverse('wagtailadmin_account_change_name'),
'label': _('Change name'),
'help_text': _('Change your first and last name on your account.'),
}
@hooks.register('register_rich_text_features')
def register_core_features(features):
# Hallo.js

View file

@ -9,7 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from unidecode import unidecode
from wagtail.admin.edit_handlers import FieldPanel
from wagtail.admin.utils import send_mail
from wagtail.admin.mail import send_mail
from wagtail.core.models import Orderable, Page
from .forms import FormBuilder, WagtailAdminFormPageForm

View file

@ -1,3 +1,4 @@
from django import VERSION as DJANGO_VERSION
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.test import TestCase
@ -65,13 +66,21 @@ class TestExcludeFromExplorer(TestCase, WagtailTestUtils):
def test_attribute_effects_explorer(self):
# The two VenuePages should appear in the venuepage list
response = self.client.get('/admin/modeladmintest/venuepage/')
self.assertContains(response, "Santa&#39;s Grotto")
self.assertContains(response, "Santa&#39;s Workshop")
if DJANGO_VERSION >= (3, 0):
self.assertContains(response, "Santa&#x27;s Grotto")
self.assertContains(response, "Santa&#x27;s Workshop")
else:
self.assertContains(response, "Santa&#39;s Grotto")
self.assertContains(response, "Santa&#39;s Workshop")
# But when viewing the children of 'Christmas' event in explorer
response = self.client.get('/admin/pages/4/')
self.assertNotContains(response, "Santa&#39;s Grotto")
self.assertNotContains(response, "Santa&#39;s Workshop")
if DJANGO_VERSION >= (3, 0):
self.assertNotContains(response, "Santa&#x27;s Grotto")
self.assertNotContains(response, "Santa&#x27;s Workshop")
else:
self.assertNotContains(response, "Santa&#39;s Grotto")
self.assertNotContains(response, "Santa&#39;s Workshop")
# But the other test page should...
self.assertContains(response, "Claim your free present!")

View file

@ -439,6 +439,23 @@ class TestDeleteViewWithProtectedRelation(TestCase, WagtailTestUtils):
self.assertFalse(Author.objects.filter(id=4).exists())
def test_post_with_1to1_dependent_object(self):
response = self.post(5)
self.assertEqual(response.status_code, 200)
self.assertContains(
response,
"'Harper Lee' is currently referenced by other objects"
)
self.assertContains(
response,
"<li><b>Solo Book:</b> To Kill a Mockingbird</li>"
)
# Author not deleted
self.assertTrue(Author.objects.filter(id=5).exists())
class TestDeleteViewModelReprPrimary(TestCase, WagtailTestUtils):
fixtures = ['modeladmintest_test.json']

View file

@ -6,11 +6,12 @@ from django.contrib.admin.options import IncorrectLookupParameters
from django.contrib.admin.utils import (
get_fields_from_path, label_for_field, lookup_needs_distinct, prepare_lookup_value, quote, unquote)
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ImproperlyConfigured, PermissionDenied, SuspiciousOperation
from django.core.exceptions import (
ImproperlyConfigured, ObjectDoesNotExist, PermissionDenied, SuspiciousOperation)
from django.core.paginator import InvalidPage, Paginator
from django.db import models
from django.db.models.fields import FieldDoesNotExist
from django.db.models.fields.related import ManyToManyField
from django.db.models.fields.related import ManyToManyField, OneToOneRel
from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import filesizeformat
from django.utils.decorators import method_decorator
@ -738,9 +739,17 @@ class DeleteView(InstanceSpecificView):
obj.field, ManyToManyField))
for rel in fields:
if rel.on_delete == models.PROTECT:
qs = getattr(self.instance, rel.get_accessor_name())
for obj in qs.all():
linked_objects.append(obj)
if isinstance(rel, OneToOneRel):
try:
obj = getattr(self.instance, rel.get_accessor_name())
except ObjectDoesNotExist:
pass
else:
linked_objects.append(obj)
else:
qs = getattr(self.instance, rel.get_accessor_name())
for obj in qs.all():
linked_objects.append(obj)
context = self.get_context_data(
protected_error=True,
linked_objects=linked_objects

View file

@ -17,7 +17,7 @@ from .models import RawSearchQuery as PostgresRawSearchQuery
from .models import IndexEntry
from .utils import (
get_content_type_pk, get_descendants_content_types_pks, get_postgresql_connections,
get_sql_weights, get_weight, unidecode)
get_sql_weights, get_weight)
EMPTY_VECTOR = SearchVector(Value(''))
@ -70,7 +70,7 @@ class Index:
def prepare_field(self, obj, field):
if isinstance(field, SearchField):
yield (field, get_weight(field.boost),
unidecode(self.prepare_value(field.get_value(obj))))
self.prepare_value(field.get_value(obj)))
elif isinstance(field, RelatedFields):
sub_obj = field.get_value(obj)
if sub_obj is None:
@ -227,16 +227,13 @@ class PostgresSearchQueryCompiler(BaseSearchQueryCompiler):
and field.field_name == field_lookup:
return self.get_search_field(sub_field_name, field.fields)
def prepare_word(self, word):
return unidecode(word)
def build_tsquery_content(self, query, group=False):
if isinstance(query, PlainText):
query_formats = []
query_params = []
for word in query.query_string.split():
query_formats.append(self.TSQUERY_WORD_FORMAT)
query_params.append(self.prepare_word(word))
query_params.append(word)
operator = self.TSQUERY_OPERATORS[query.operator]
query_format = operator.join(query_formats)
if group and len(query_formats) > 1:

View file

@ -0,0 +1,44 @@
import unittest
from django.conf import settings
from django.db import connection
from django.test import TestCase
from wagtail.search.backends import get_search_backend
from wagtail.tests.search import models
class TestPostgresStemming(TestCase):
def setUp(self):
backend_name = "wagtail.contrib.postgres_search.backend"
for conf in settings.WAGTAILSEARCH_BACKENDS.values():
if conf['BACKEND'] == backend_name:
break
else:
raise unittest.SkipTest("Only for %s" % backend_name)
self.backend = get_search_backend(backend_name)
def test_ru_stemming(self):
with connection.cursor() as cursor:
cursor.execute(
"SET default_text_search_config TO 'pg_catalog.russian'"
)
ru_book = models.Book.objects.create(
title="Голубое сало", publication_date="1999-05-01",
number_of_pages=352
)
self.backend.add(ru_book)
results = self.backend.search("Голубое", models.Book)
self.assertEqual(list(results), [ru_book])
results = self.backend.search("Голубая", models.Book)
self.assertEqual(list(results), [ru_book])
results = self.backend.search("Голубой", models.Book)
self.assertEqual(list(results), [ru_book])
ru_book.delete()

View file

@ -5,13 +5,6 @@ from django.db import connections
from wagtail.search.index import Indexed, RelatedFields, SearchField
try:
# Only use the GPLv2 licensed unidecode if it's installed.
from unidecode import unidecode
except ImportError:
def unidecode(value):
return value
def get_postgresql_connections():
return [connection for connection in connections.all()

View file

@ -6,8 +6,8 @@ from django.utils.translation import ugettext as _
from django.views.decorators.vary import vary_on_headers
from wagtail.admin import messages
from wagtail.admin.auth import PermissionPolicyChecker, permission_denied
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.utils import PermissionPolicyChecker, permission_denied
from wagtail.contrib.redirects import models
from wagtail.contrib.redirects.forms import RedirectForm
from wagtail.contrib.redirects.permissions import permission_policy

View file

@ -5,8 +5,8 @@ from django.utils.translation import ugettext as _
from django.views.decorators.vary import vary_on_headers
from wagtail.admin import messages
from wagtail.admin.auth import any_permission_required, permission_required
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.utils import any_permission_required, permission_required
from wagtail.contrib.search_promotions import forms
from wagtail.search import forms as search_forms
from wagtail.search.models import Query

View file

@ -43,7 +43,12 @@ class TableInput(forms.HiddenInput):
def get_context(self, name, value, attrs=None):
context = super().get_context(name, value, attrs)
table_caption = ''
if value and value != 'null':
table_caption = json.loads(value).get('table_caption', '')
context['widget']['table_options_json'] = json.dumps(self.table_options)
context['widget']['table_caption'] = table_caption
return context
@ -59,6 +64,7 @@ class TableBlock(FieldBlock):
"""
self.table_options = self.get_table_options(table_options=table_options)
self.field_options = {'required': required, 'help_text': help_text}
super().__init__(**kwargs)
@cached_property
@ -97,6 +103,7 @@ class TableBlock(FieldBlock):
'table_header': table_header,
'first_col_is_header': first_col_is_header,
'html_renderer': self.is_html_renderer(),
'table_caption': value.get('table_caption'),
'data': value['data'][1:] if table_header else value.get('data', [])
})

View file

@ -4,9 +4,11 @@ function initTable(id, tableOptions) {
var containerId = id + '-handsontable-container';
var tableHeaderCheckboxId = id + '-handsontable-header';
var colHeaderCheckboxId = id + '-handsontable-col-header';
var tableCaptionId = id + '-handsontable-col-caption';
var hiddenStreamInput = $('#' + id);
var tableHeaderCheckbox = $('#' + tableHeaderCheckboxId);
var colHeaderCheckbox = $('#' + colHeaderCheckboxId);
var tableCaption = $('#' + tableCaptionId);
var hot;
var defaultOptions;
var finalOptions = {};
@ -18,6 +20,7 @@ function initTable(id, tableOptions) {
var structureEvent;
var dataForForm = null;
var isInitialized = false;
var getWidth = function() {
return $('.widget-table_input').closest('.sequence-member-inner').width();
};
@ -55,6 +58,9 @@ function initTable(id, tableOptions) {
if (dataForForm.hasOwnProperty('first_col_is_header')) {
colHeaderCheckbox.prop('checked', dataForForm.first_col_is_header);
}
if (dataForForm.hasOwnProperty('table_caption')) {
tableCaption.prop('value', dataForForm.table_caption);
}
}
if (!tableOptions.hasOwnProperty('width') || !tableOptions.hasOwnProperty('height')) {
@ -88,7 +94,8 @@ function initTable(id, tableOptions) {
data: hot.getData(),
cell: getCellsClassnames(),
first_row_is_table_header: tableHeaderCheckbox.prop('checked'),
first_col_is_header: colHeaderCheckbox.prop('checked')
first_col_is_header: colHeaderCheckbox.prop('checked'),
table_caption: tableCaption.val()
}));
};
@ -123,6 +130,10 @@ function initTable(id, tableOptions) {
persist();
});
tableCaption.on('change', function() {
persist();
});
defaultOptions = {
afterChange: cellEvent,
afterCreateCol: structureEvent,

View file

@ -1,6 +1,9 @@
{% load table_block_tags %}
<table>
{% if table_caption %}
<caption>{{ table_caption }}</caption>
{% endif %}
{% if table_header %}
<thead>
<tr>

View file

@ -19,6 +19,16 @@
</div>
</div>
<br/>
<div class="field">
<label for="{{ widget.attrs.id }}-handsontable-col-caption">{% trans 'Table caption' %}</label>
<div class="field-content">
<div class="input">
<input type="text" id="{{ widget.attrs.id }}-handsontable-col-caption" name="handsontable-col-caption" value="{{ widget.table_caption }}"/>
</div>
<p class="help">{% trans 'A heading that identifies the overall topic of the table, and is useful for screen reader users' %}</p>
</div>
</div>
<br/>
<div id="{{ widget.attrs.id }}-handsontable-container"></div>
{% include 'django/forms/widgets/hidden.html' %}
<script>initTable("{{ widget.attrs.id|escapejs }}", {{ widget.table_options_json|safe }});</script>

View file

@ -241,6 +241,30 @@ class TestTableBlock(TestCase):
self.assertIn("<div>A fascinating table.</div>", result)
def test_table_block_caption_render(self):
"""
Test a generic render with caption.
"""
value = {'table_caption': 'caption', 'first_row_is_table_header': False,
'first_col_is_header': False,
'data': [['Test 1', 'Test 2', 'Test 3'], [None, None, None],
[None, None, None]]}
block = TableBlock()
result = block.render(value)
expected = """
<table>
<caption>caption</caption>
<tbody>
<tr><td>Test 1</td><td>Test 2</td><td>Test 3</td></tr>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
</tbody>
</table>
"""
self.assertHTMLEqual(result, expected)
self.assertIn('Test 2', result)
class TestTableBlockForm(WagtailTestUtils, SimpleTestCase):
def setUp(self):

View file

@ -3,6 +3,7 @@ import logging
from collections import defaultdict
from io import StringIO
from urllib.parse import urlparse
from warnings import warn
from django.conf import settings
from django.contrib.auth.models import Group, Permission
@ -32,6 +33,8 @@ from wagtail.core.sites import get_site_for_hostname
from wagtail.core.url_routing import RouteResult
from wagtail.core.utils import WAGTAIL_APPEND_SLASH, camelcase_to_underscore, resolve_model_string
from wagtail.search import index
from wagtail.utils.deprecation import RemovedInWagtail29Warning
logger = logging.getLogger('wagtail.core')
@ -1216,16 +1219,51 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
user_perms = UserPagePermissionsProxy(user)
return user_perms.for_page(self)
def dummy_request(self, original_request=None, **meta):
def make_preview_request(self, original_request=None, preview_mode=None, extra_request_attrs=None):
"""
Construct a HttpRequest object that is, as far as possible, representative of ones that would
receive this page as a response. Used for previewing / moderation and any other place where we
Simulate a request to this page, by constructing a fake HttpRequest object that is (as far
as possible) representative of a real request to this page's front-end URL, and invoking
serve_preview with that request (and the given preview_mode).
Used for previewing / moderation and any other place where we
want to display a view of this page in the admin interface without going through the regular
page routing logic.
If you pass in a real request object as original_request, additional information (e.g. client IP, cookies)
will be included in the dummy request.
"""
dummy_meta = self._get_dummy_headers(original_request)
request = WSGIRequest(dummy_meta)
# Add a flag to let middleware know that this is a dummy request.
request.is_dummy = True
if extra_request_attrs:
for k, v in extra_request_attrs.items():
setattr(request, k, v)
page = self
# Build a custom django.core.handlers.BaseHandler subclass that invokes serve_preview as
# the eventual view function called at the end of the middleware chain, rather than going
# through the URL resolver
class Handler(BaseHandler):
def _get_response(self, request):
response = page.serve_preview(request, preview_mode)
if hasattr(response, 'render') and callable(response.render):
response = response.render()
return response
# Invoke this custom handler.
handler = Handler()
handler.load_middleware()
return handler.get_response(request)
def _get_dummy_headers(self, original_request=None):
"""
Return a dict of META information to be included in a faked HttpRequest object to pass to
serve_preview.
"""
url = self.full_url
if url:
url_info = urlparse(url)
@ -1279,6 +1317,16 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
if header in original_request.META:
dummy_values[header] = original_request.META[header]
return dummy_values
def dummy_request(self, original_request=None, **meta):
warn(
"Page.dummy_request is deprecated. Use Page.make_preview_request instead",
category=RemovedInWagtail29Warning
)
dummy_values = self._get_dummy_headers(original_request)
# Add additional custom metadata sent by the caller.
dummy_values.update(**meta)

View file

@ -66,6 +66,8 @@ class LinkRewriter:
link_type = 'external'
elif href.startswith('mailto:'):
link_type = 'email'
elif href.startswith('#'):
link_type = 'anchor'
if not link_type:
# otherwise return ordinary links without a linktype unchanged
@ -74,7 +76,7 @@ class LinkRewriter:
try:
rule = self.link_rules[link_type]
except KeyError:
if link_type in ['email', 'external']:
if link_type in ['email', 'external', 'anchor']:
# If no rule is registered for supported types
# return ordinary links without a linktype unchanged
return match.group(0)

View file

@ -31,6 +31,10 @@ def pageurl(context, page, fallback=None):
# request.site not available in the current context; fall back on page.url
return page.url
if current_site is None:
# request.site is set to None; fall back on page.url
return page.url
# Pass page.relative_url the request object, which may contain a cached copy of
# Site.get_site_root_paths()
# This avoids page.relative_url having to make a database/cache fetch for this list
@ -48,13 +52,15 @@ def slugurl(context, slug):
that matches the slug on any site.
"""
page = None
try:
current_site = context['request'].site
except (KeyError, AttributeError):
# No site object found - allow the fallback below to take place.
page = None
pass
else:
page = Page.objects.in_site(current_site).filter(slug=slug).first()
if current_site is not None:
page = Page.objects.in_site(current_site).filter(slug=slug).first()
# If no page is found, fall back to searching the whole tree.
if page is None:

View file

@ -1444,13 +1444,14 @@ class TestIssue2024(TestCase):
self.assertEqual(event_index.content_type, ContentType.objects.get_for_model(Page))
@override_settings(ALLOWED_HOSTS=['localhost'])
class TestDummyRequest(TestCase):
class TestMakePreviewRequest(TestCase):
fixtures = ['test.json']
def test_dummy_request_for_accessible_page(self):
def test_make_preview_request_for_accessible_page(self):
event_index = Page.objects.get(url_path='/home/events/')
request = event_index.dummy_request()
response = event_index.make_preview_request()
self.assertEqual(response.status_code, 200)
request = response.context_data['request']
# request should have the correct path and hostname for this page
self.assertEqual(request.path, '/events/')
@ -1471,11 +1472,13 @@ class TestDummyRequest(TestCase):
self.assertIn('wsgi.multiprocess', request.META)
self.assertIn('wsgi.run_once', request.META)
def test_dummy_request_for_accessible_page_https(self):
def test_make_preview_request_for_accessible_page_https(self):
Site.objects.update(port=443)
event_index = Page.objects.get(url_path='/home/events/')
request = event_index.dummy_request()
response = event_index.make_preview_request()
self.assertEqual(response.status_code, 200)
request = response.context_data['request']
# request should have the correct path and hostname for this page
self.assertEqual(request.path, '/events/')
@ -1496,11 +1499,13 @@ class TestDummyRequest(TestCase):
self.assertIn('wsgi.multiprocess', request.META)
self.assertIn('wsgi.run_once', request.META)
def test_dummy_request_for_accessible_page_non_standard_port(self):
def test_make_preview_request_for_accessible_page_non_standard_port(self):
Site.objects.update(port=8888)
event_index = Page.objects.get(url_path='/home/events/')
request = event_index.dummy_request()
response = event_index.make_preview_request()
self.assertEqual(response.status_code, 200)
request = response.context_data['request']
# request should have the correct path and hostname for this page
self.assertEqual(request.path, '/events/')
@ -1521,7 +1526,7 @@ class TestDummyRequest(TestCase):
self.assertIn('wsgi.multiprocess', request.META)
self.assertIn('wsgi.run_once', request.META)
def test_dummy_request_for_accessible_page_with_original_request(self):
def test_make_preview_request_for_accessible_page_with_original_request(self):
event_index = Page.objects.get(url_path='/home/events/')
original_headers = {
'REMOTE_ADDR': '192.168.0.1',
@ -1532,7 +1537,9 @@ class TestDummyRequest(TestCase):
}
factory = RequestFactory(**original_headers)
original_request = factory.get('/home/events/')
request = event_index.dummy_request(original_request)
response = event_index.make_preview_request(original_request)
self.assertEqual(response.status_code, 200)
request = response.context_data['request']
# request should have the all the special headers we set in original_request
self.assertEqual(request.META['REMOTE_ADDR'], original_request.META['REMOTE_ADDR'])
@ -1557,9 +1564,11 @@ class TestDummyRequest(TestCase):
self.assertIn('wsgi.run_once', request.META)
@override_settings(ALLOWED_HOSTS=['production.example.com'])
def test_dummy_request_for_inaccessible_page_should_use_valid_host(self):
def test_make_preview_request_for_inaccessible_page_should_use_valid_host(self):
root_page = Page.objects.get(url_path='/')
request = root_page.dummy_request()
response = root_page.make_preview_request()
self.assertEqual(response.status_code, 200)
request = response.context_data['request']
# in the absence of an actual Site record where we can access this page,
# dummy_request should still provide a hostname that Django's host header
@ -1569,7 +1578,9 @@ class TestDummyRequest(TestCase):
@override_settings(ALLOWED_HOSTS=['*'])
def test_dummy_request_for_inaccessible_page_with_wildcard_allowed_hosts(self):
root_page = Page.objects.get(url_path='/')
request = root_page.dummy_request()
response = root_page.make_preview_request()
self.assertEqual(response.status_code, 200)
request = response.context_data['request']
# '*' is not a valid hostname, so ensure that we replace it with something sensible
self.assertNotEqual(request.META['HTTP_HOST'], '*')

View file

@ -108,11 +108,13 @@ class TestLinkRewriterTagReplacing(TestCase):
self.assertEqual(page_type_link, '<a href="/article/3">')
# but it should also be able to handle other supported
# link types (email, external) even if no rules is provided
# link types (email, external, anchor) even if no rules is provided
external_type_link = rewriter('<a href="https://wagtail.io/">')
self.assertEqual(external_type_link, '<a href="https://wagtail.io/">')
email_type_link = rewriter('<a href="mailto:test@wagtail.io">')
self.assertEqual(email_type_link, '<a href="mailto:test@wagtail.io">')
anchor_type_link = rewriter('<a href="#test">')
self.assertEqual(anchor_type_link, '<a href="#test">')
# As well as link which don't have any linktypes
link_without_linktype = rewriter('<a data-link="https://wagtail.io">')
@ -131,6 +133,7 @@ class TestLinkRewriterTagReplacing(TestCase):
'page': lambda attrs: '<a href="/article/{}">'.format(attrs['id']),
'external': lambda attrs: '<a rel="nofollow" href="{}">'.format(attrs['href']),
'email': lambda attrs: '<a data-email="true" href="{}">'.format(attrs['href']),
'anchor': lambda attrs: '<a data-anchor="true" href="{}">'.format(attrs['href']),
'custom': lambda attrs: '<a data-phone="true" href="{}">'.format(attrs['href']),
}
rewriter = LinkRewriter(rules)
@ -146,6 +149,8 @@ class TestLinkRewriterTagReplacing(TestCase):
self.assertEqual(external_type_link_http, '<a rel="nofollow" href="http://wagtail.io/">')
email_type_link = rewriter('<a href="mailto:test@wagtail.io">')
self.assertEqual(email_type_link, '<a data-email="true" href="mailto:test@wagtail.io">')
anchor_type_link = rewriter('<a href="#test">')
self.assertEqual(anchor_type_link, '<a data-anchor="true" href="#test">')
# But not the unsupported ones.
link_with_no_linktype = rewriter('<a href="tel:+4917640206387">')

View file

@ -48,6 +48,16 @@ class TestPageUrlTags(TestCase):
result = tpl.render(template.Context({'page': page, 'request': HttpRequest()}))
self.assertIn('<a href="/events/">Events</a>', result)
def test_pageurl_with_null_site_in_request(self):
page = Page.objects.get(url_path='/home/events/')
tpl = template.Template('''{% load wagtailcore_tags %}<a href="{% pageurl page %}">{{ page.title }}</a>''')
# 'request' object in context, but site is None
request = HttpRequest()
request.site = None
result = tpl.render(template.Context({'page': page, 'request': request}))
self.assertIn('<a href="/events/">Events</a>', result)
def test_bad_pageurl(self):
tpl = template.Template('''{% load wagtailcore_tags %}<a href="{% pageurl page %}">{{ page.title }}</a>''')
@ -96,6 +106,13 @@ class TestPageUrlTags(TestCase):
result = slugurl(template.Context({'request': HttpRequest()}), 'events')
self.assertEqual(result, '/events/')
def test_slugurl_with_null_site_in_request(self):
# 'request' object in context, but site is None
request = HttpRequest()
request.site = None
result = slugurl(template.Context({'request': request}), 'events')
self.assertEqual(result, '/events/')
class TestSiteRootPathsCache(TestCase):
fixtures = ['test.json']

View file

@ -10,7 +10,7 @@ from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from taggit.managers import TaggableManager
from wagtail.admin.utils import get_object_usage
from wagtail.admin.models import get_object_usage
from wagtail.core.models import CollectionMember
from wagtail.search import index
from wagtail.search.queryset import SearchableQuerySetMixin

View file

@ -3,9 +3,9 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.translation import ugettext as _
from wagtail.admin.auth import PermissionPolicyChecker
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.admin.utils import PermissionPolicyChecker
from wagtail.core import hooks
from wagtail.core.models import Collection
from wagtail.documents.forms import get_document_form

View file

@ -7,8 +7,9 @@ from django.utils.translation import ugettext as _
from django.views.decorators.vary import vary_on_headers
from wagtail.admin import messages
from wagtail.admin.auth import PermissionPolicyChecker, permission_denied
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.utils import PermissionPolicyChecker, permission_denied, popular_tags_for_model
from wagtail.admin.models import popular_tags_for_model
from wagtail.core.models import Collection
from wagtail.documents.forms import get_document_form
from wagtail.documents.models import get_document_model

View file

@ -6,7 +6,7 @@ from django.utils.encoding import force_text
from django.views.decorators.http import require_POST
from django.views.decorators.vary import vary_on_headers
from wagtail.admin.utils import PermissionPolicyChecker
from wagtail.admin.auth import PermissionPolicyChecker
from wagtail.core.models import Collection
from wagtail.search.backends import get_search_backends

View file

@ -8,10 +8,10 @@ from django.utils.translation import ugettext, ungettext
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail.admin.menu import MenuItem
from wagtail.admin.navigation import get_site_for_user
from wagtail.admin.rich_text import HalloPlugin
from wagtail.admin.search import SearchArea
from wagtail.admin.site_summary import SummaryItem
from wagtail.admin.utils import get_site_for_user
from wagtail.core import hooks
from wagtail.core.models import BaseViewRestriction
from wagtail.core.wagtail_hooks import require_wagtail_login

View file

@ -17,7 +17,7 @@ from taggit.managers import TaggableManager
from unidecode import unidecode
from willow.image import Image as WillowImage
from wagtail.admin.utils import get_object_usage
from wagtail.admin.models import get_object_usage
from wagtail.core import hooks
from wagtail.core.models import CollectionMember
from wagtail.images.exceptions import InvalidFilterSpecError

View file

@ -3,6 +3,9 @@
{% trans "Choose an image" as choose_str %}
{% include "wagtailadmin/shared/header.html" with title=choose_str merged=1 tabbed=1 icon="image" %}
{{ uploadform.media.js }}
{{ uploadform.media.css }}
{% if uploadform %}
<ul class="tab-nav merged">
<li class="{% if not uploadform.errors %}active{% endif %}"><a href="#search" >{% trans "Search" %}</a></li>

View file

@ -6,6 +6,8 @@
{% block extra_js %}
{{ block.super }}
{{ form.media.js }}
{% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
<script>
$(function() {
@ -16,6 +18,11 @@
</script>
{% endblock %}
{% block extra_css %}
{{ block.super }}
{{ form.media.css }}
{% endblock %}
{% block content %}
{% trans "Add image" as add_str %}
{% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %}

View file

@ -4,6 +4,8 @@
{% block extra_css %}
{{ block.super }}
{{ form.media.css }}
<!-- Focal point chooser -->
<link rel="stylesheet" href="{% static 'wagtailimages/css/vendor/jquery.Jcrop.min.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'wagtailimages/css/focal-point-chooser.css' %}" type="text/css">
@ -12,6 +14,8 @@
{% block extra_js %}
{{ block.super }}
{{ form.media.js }}
{% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
<script>
$(function() {

View file

@ -7,6 +7,8 @@
{% block extra_css %}
{{ block.super }}
{{ form_media.css }}
<link rel="stylesheet" href="{% static 'wagtailimages/css/add-multiple.css' %}" type="text/css" />
{% endblock %}
@ -75,6 +77,8 @@
{% block extra_js %}
{{ block.super }}
{{ form_media.js }}
<!-- this exact order of plugins is vital -->
<script src="{% static 'wagtailimages/js/vendor/load-image.min.js' %}"></script>
<script src="{% static 'wagtailimages/js/vendor/canvas-to-blob.min.js' %}"></script>

View file

@ -10,6 +10,7 @@ from django.utils.http import RFC3986_SUBDELIMS, urlquote
from wagtail.core.models import Collection, GroupCollectionPermission
from wagtail.images.views.serve import generate_signature
from wagtail.tests.testapp.models import CustomImage
from wagtail.tests.utils import WagtailTestUtils
from .utils import Image, get_test_image_file
@ -108,6 +109,9 @@ class TestImageAddView(TestCase, WagtailTestUtils):
# Ensure the form supports file uploads
self.assertContains(response, 'enctype="multipart/form-data"')
# draftail should NOT be a standard JS include on this page
self.assertNotContains(response, 'wagtailadmin/js/draftail.js')
def test_get_with_collections(self):
root_collection = Collection.get_first_root_node()
root_collection.add_child(name="Evil plans")
@ -119,6 +123,21 @@ class TestImageAddView(TestCase, WagtailTestUtils):
self.assertContains(response, '<label for="id_collection">')
self.assertContains(response, "Evil plans")
@override_settings(WAGTAILIMAGES_IMAGE_MODEL='tests.CustomImage')
def test_get_with_custom_image_model(self):
response = self.get()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailimages/images/add.html')
# Ensure the form supports file uploads
self.assertContains(response, 'enctype="multipart/form-data"')
# custom fields should be included
self.assertContains(response, 'name="fancy_caption"')
# form media should be imported
self.assertContains(response, 'wagtailadmin/js/draftail.js')
def test_add(self):
response = self.post({
'title': "Test image",
@ -325,6 +344,11 @@ class TestImageEditView(TestCase, WagtailTestUtils):
# Ensure the form supports file uploads
self.assertContains(response, 'enctype="multipart/form-data"')
# draftail should NOT be a standard JS include on this page
# (see TestImageEditViewWithCustomImageModel - this confirms that form media
# definitions are being respected)
self.assertNotContains(response, 'wagtailadmin/js/draftail.js')
@override_settings(WAGTAIL_USAGE_COUNT_ENABLED=True)
def test_with_usage_count(self):
response = self.get()
@ -514,6 +538,34 @@ class TestImageEditView(TestCase, WagtailTestUtils):
self.assertContains(response, 'data-original-width="1024"')
@override_settings(WAGTAILIMAGES_IMAGE_MODEL='tests.CustomImage')
class TestImageEditViewWithCustomImageModel(TestCase, WagtailTestUtils):
def setUp(self):
self.login()
# Create an image to edit
self.image = CustomImage.objects.create(
title="Test image",
file=get_test_image_file(),
)
self.storage = self.image.file.storage
def get(self, params={}):
return self.client.get(reverse('wagtailimages:edit', args=(self.image.id,)), params)
def test_get_with_custom_image_model(self):
response = self.get()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailimages/images/edit.html')
# Ensure the form supports file uploads
self.assertContains(response, 'enctype="multipart/form-data"')
# form media should be imported
self.assertContains(response, 'wagtailadmin/js/draftail.js')
class TestImageDeleteView(TestCase, WagtailTestUtils):
def setUp(self):
self.login()
@ -571,6 +623,23 @@ class TestImageChooserView(TestCase, WagtailTestUtils):
self.assertEqual(response_json['step'], 'chooser')
self.assertTemplateUsed(response, 'wagtailimages/chooser/chooser.html')
# draftail should NOT be a standard JS include on this page
self.assertNotIn('wagtailadmin/js/draftail.js', response_json['html'])
@override_settings(WAGTAILIMAGES_IMAGE_MODEL='tests.CustomImage')
def test_with_custom_image_model(self):
response = self.get()
self.assertEqual(response.status_code, 200)
response_json = json.loads(response.content.decode())
self.assertEqual(response_json['step'], 'chooser')
self.assertTemplateUsed(response, 'wagtailimages/chooser/chooser.html')
# custom form fields should be present
self.assertIn('name="image-chooser-upload-fancy_caption"', response_json['html'])
# form media imports should appear on the page
self.assertIn('wagtailadmin/js/draftail.js', response_json['html'])
def test_search(self):
response = self.get({'q': "Hello"})
self.assertEqual(response.status_code, 200)
@ -898,6 +967,11 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils):
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailimages/multiple/add.html')
# draftail should NOT be a standard JS include on this page
# (see TestMultipleImageUploaderWithCustomImageModel - this confirms that form media
# definitions are being respected)
self.assertNotContains(response, 'wagtailadmin/js/draftail.js')
@override_settings(WAGTAILIMAGES_MAX_UPLOAD_SIZE=1000)
def test_add_max_file_size_context_variables(self):
response = self.client.get(reverse('wagtailimages:add_multiple'))
@ -1095,6 +1169,123 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils):
self.assertEqual(response.status_code, 400)
@override_settings(WAGTAILIMAGES_IMAGE_MODEL='tests.CustomImage')
class TestMultipleImageUploaderWithCustomImageModel(TestCase, WagtailTestUtils):
"""
This tests the multiple image upload views located in wagtailimages/views/multiple.py
with a custom image model
"""
def setUp(self):
self.login()
# Create an image for running tests on
self.image = CustomImage.objects.create(
title="Test image",
file=get_test_image_file(),
)
def test_add(self):
"""
This tests that the add view responds correctly on a GET request
"""
# Send request
response = self.client.get(reverse('wagtailimages:add_multiple'))
# Check response
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailimages/multiple/add.html')
# response should include form media for the image edit form
self.assertContains(response, 'wagtailadmin/js/draftail.js')
def test_add_post(self):
"""
This tests that a POST request to the add view saves the image and returns an edit form
"""
response = self.client.post(reverse('wagtailimages:add_multiple'), {
'files[]': SimpleUploadedFile('test.png', get_test_image_file().file.getvalue()),
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
self.assertTemplateUsed(response, 'wagtailimages/multiple/edit_form.html')
# Check image
self.assertIn('image', response.context)
self.assertEqual(response.context['image'].title, 'test.png')
self.assertTrue(response.context['image'].file_size)
self.assertTrue(response.context['image'].file_hash)
# Check form
self.assertIn('form', response.context)
self.assertEqual(response.context['form'].initial['title'], 'test.png')
self.assertIn('caption', response.context['form'].fields)
self.assertNotIn('not_editable_field', response.context['form'].fields)
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('image_id', response_json)
self.assertIn('form', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['image_id'], response.context['image'].id)
self.assertTrue(response_json['success'])
def test_edit_post(self):
"""
This tests that a POST request to the edit view edits the image
"""
# Send request
response = self.client.post(reverse('wagtailimages:edit_multiple', args=(self.image.id, )), {
('image-%d-title' % self.image.id): "New title!",
('image-%d-tags' % self.image.id): "",
('image-%d-caption' % self.image.id): "a boot stamping on a human face, forever",
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('image_id', response_json)
self.assertNotIn('form', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['image_id'], self.image.id)
self.assertTrue(response_json['success'])
# check that image has been updated
new_image = CustomImage.objects.get(id=self.image.id)
self.assertEqual(new_image.title, "New title!")
self.assertEqual(new_image.caption, "a boot stamping on a human face, forever")
def test_delete_post(self):
"""
This tests that a POST request to the delete view deletes the image
"""
# Send request
response = self.client.post(reverse(
'wagtailimages:delete_multiple', args=(self.image.id, )
), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Make sure the image is deleted
self.assertFalse(Image.objects.filter(id=self.image.id).exists())
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('image_id', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['image_id'], self.image.id)
self.assertTrue(response_json['success'])
# check that image has been deleted
self.assertEqual(CustomImage.objects.filter(id=self.image.id).count(), 0)
class TestURLGeneratorView(TestCase, WagtailTestUtils):
def setUp(self):
# Create an image for running tests on

View file

@ -569,6 +569,7 @@ class TestGetImageForm(TestCase, WagtailTestUtils):
'focal_point_width',
'focal_point_height',
'caption',
'fancy_caption',
])
def test_file_field(self):

View file

@ -3,9 +3,10 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.translation import ugettext as _
from wagtail.admin.auth import PermissionPolicyChecker
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.admin.utils import PermissionPolicyChecker, popular_tags_for_model
from wagtail.admin.models import popular_tags_for_model
from wagtail.core import hooks
from wagtail.core.models import Collection
from wagtail.images import get_image_model

View file

@ -9,8 +9,9 @@ from django.utils.translation import ugettext as _
from django.views.decorators.vary import vary_on_headers
from wagtail.admin import messages
from wagtail.admin.auth import PermissionPolicyChecker, permission_denied
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.utils import PermissionPolicyChecker, permission_denied, popular_tags_for_model
from wagtail.admin.models import popular_tags_for_model
from wagtail.core.models import Collection, Site
from wagtail.images import get_image_model
from wagtail.images.exceptions import InvalidFilterSpecError

View file

@ -6,7 +6,7 @@ from django.utils.encoding import force_text
from django.views.decorators.http import require_POST
from django.views.decorators.vary import vary_on_headers
from wagtail.admin.utils import PermissionPolicyChecker
from wagtail.admin.auth import PermissionPolicyChecker
from wagtail.core.models import Collection
from wagtail.images import get_image_model
from wagtail.images.fields import ALLOWED_EXTENSIONS
@ -93,16 +93,19 @@ def add(request):
'error_message': '\n'.join(['\n'.join([force_text(i) for i in v]) for k, v in form.errors.items()]),
})
else:
# Instantiate a dummy copy of the form that we can retrieve validation messages and media from;
# actual rendering of forms will happen on AJAX POST rather than here
form = ImageForm(user=request.user)
return render(request, 'wagtailimages/multiple/add.html', {
'max_filesize': form.fields['file'].max_upload_size,
'help_text': form.fields['file'].help_text,
'allowed_extensions': ALLOWED_EXTENSIONS,
'error_max_file_size': form.fields['file'].error_messages['file_too_large_unknown_size'],
'error_accepted_file_types': form.fields['file'].error_messages['invalid_image'],
'collections': collections_to_choose,
})
return render(request, 'wagtailimages/multiple/add.html', {
'max_filesize': form.fields['file'].max_upload_size,
'help_text': form.fields['file'].help_text,
'allowed_extensions': ALLOWED_EXTENSIONS,
'error_max_file_size': form.fields['file'].error_messages['file_too_large_unknown_size'],
'error_accepted_file_types': form.fields['file'].error_messages['invalid_image'],
'collections': collections_to_choose,
'form_media': form.media,
})
@require_POST

View file

@ -6,10 +6,10 @@ from django.utils.translation import ugettext, ungettext
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail.admin.menu import MenuItem
from wagtail.admin.navigation import get_site_for_user
from wagtail.admin.rich_text import HalloPlugin
from wagtail.admin.search import SearchArea
from wagtail.admin.site_summary import SummaryItem
from wagtail.admin.utils import get_site_for_user
from wagtail.core import hooks
from wagtail.images import admin_urls, get_image_model, image_operations
from wagtail.images.api.admin.endpoints import ImagesAdminAPIEndpoint

View file

@ -225,9 +225,9 @@ class BaseField:
if hasattr(field, 'get_searchable_content'):
value = field.get_searchable_content(value)
elif isinstance(field, TaggableManager):
# Special case for tags fields. Convert QuerySet of TaggedItems into QuerySet of Tags
Tag = field.remote_field.model
value = Tag.objects.filter(id__in=value.values_list('tag_id', flat=True))
# As of django-taggit 1.0, value_from_object returns a list of Tag objects,
# which matches what we want
pass
elif isinstance(field, RelatedField):
# The type of the ForeignKey may have a get_searchable_content method that we should
# call. Firstly we need to find the field its referencing but it may be referencing

View file

@ -3,7 +3,7 @@ from django.core import checks
from django.urls import reverse
from wagtail.admin.checks import check_panels_in_model
from wagtail.admin.utils import get_object_usage
from wagtail.admin.models import get_object_usage
SNIPPET_MODELS = []

Some files were not shown because too many files have changed in this diff Show more