diff --git a/.travis.yml b/.travis.yml index 8764f2859..326c22d01 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,12 @@ -# Python releases to test language: python +# Test matrix python: - - 2.7 -# Django releases + - 2.7 + - 3.2 + - 3.4 env: - - DJANGO_VERSION=Django==1.6.2 + - DJANGO_VERSION=Django==1.6.5 + #- DJANGO_VERSION=Django==1.7.0 # Services services: - redis-server diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 9729a290e..8f391a29a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -4,6 +4,11 @@ Changelog 0.4 (xx.xx.20xx) ~~~~~~~~~~~~~~~~ * ElasticUtils/pyelasticsearch swapped for elasticsearch-py + * Python 3.2, 3.3 and 3.4 support + * Added scheduled publishing + * Added frontend cache invalidator + * Added sitemap generator + * Added notification preferences * Added 'original' as a resizing rule supported by the 'image' tag * Hallo.js updated to version 1.0.4 * Snippets are now ordered alphabetically @@ -14,13 +19,23 @@ Changelog * Added styleguide (mainly for wagtail developers) * Aesthetic improvements to preview experience * 'image' tag now accepts extra keyword arguments to be output as attributes on the img tag + * Login screen redirects to dashboard if user is already logged in + * Renamed some template tag libraries + * Any extra arguments given to serve are now passed through to get_context and get_template * Added an 'attrs' property to image rendition objects to output src, width, height and alt attributes all in one go * Added 'construct_whitelister_element_rules' hook for customising the HTML whitelist used when saving rich text fields + * Added 'in_menu' and 'not_in_menu' methods to PageQuerySet + * Added 'get_next_siblings' and 'get_prev_siblings' to Page + * Added init_new_page signal + * Added page_published signal + * Added copy method to Page to allow copying of pages * Fix: Animated GIFs are now coalesced before resizing * Fix: Wand backend clones images before modifying them * Fix: Admin breadcrumb now positioned correctly on mobile * Fix: Page chooser breadcrumb now updates the chooser modal instead of linking to Explorer * Fix: Embeds - Fixed crash when no HTML field is sent back from the embed provider + * Fix: Multiple sites with same hostname but different ports are now allowed + * Fix: No longer possible to create multiple sites with is_default_site = True 0.3.1 (03.06.2014) ~~~~~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index 0e68b8c6b..f0dc14abf 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ :target: https://travis-ci.org/torchbox/wagtail .. image:: https://coveralls.io/repos/torchbox/wagtail/badge.png?branch=master&zxcv1 - :target: https://coveralls.io/r/torchbox/wagtail?branch=master + :target: https://coveralls.io/r/torchbox/wagtail?branch=master .. image:: https://pypip.in/v/wagtail/badge.png?zxcv :target: https://crate.io/packages/wagtail/ @@ -24,7 +24,9 @@ Wagtail is a Django content management system built originally for the `Royal Co * Support for tree-based content organisation * Optional preview->submit->approve workflow * Fast out of the box. `Varnish `_-friendly if you need it -* Excellent test coverage +* A simple `form builder `_ +* Optional `static site generation `_ +* Excellent `test coverage `_ Find out more at `wagtail.io `_. @@ -35,16 +37,25 @@ Getting started * To get you up and running quickly, we've provided a demonstration site with all the configuration in place, at `github.com/torchbox/wagtaildemo `_; see the `README `_ for installation instructions. * See the `Getting Started `_ docs for installation (with the demo app) on a fresh Debian/Ubuntu box with production-ready dependencies, on OS X and on a Vagrant box. * `Serafeim Papastefanos `_ has written a `tutorial `_ with all the steps to build a simple Wagtail site from scratch. +* We've also provided a skeletal django-template to get started on a blank site: https://github.com/torchbox/wagtail-template Documentation ~~~~~~~~~~~~~ -Available at `wagtail.readthedocs.org `_. and always being updated. +Available at `wagtail.readthedocs.org `_ and always being updated. Compatibility ~~~~~~~~~~~~~ -Wagtail supports Django 1.6.2+ on Python 2.6 and 2.7. Django 1.7 and Python 3 support are in progress. +Wagtail supports Django 1.6.2+ on Python 2.6, 2.7, 3.2, 3.3 and 3.4. + +Django 1.7 support is in progress pending further release candidate testing. + +Wagtail's dependencies are summarised at `requirements.io `_. Contributing ~~~~~~~~~~~~ -If you're a Python or Django developer, fork the repo and get stuck in! Send us a useful pull request and we'll post you a `t-shirt `_. Our immediate priorities are better docs, more tests, internationalisation and localisation. +If you're a Python or Django developer, fork the repo and get stuck in! + +We suggest you start by checking the `Help develop me! `_ label. + +Send us a useful pull request and we'll post you a `t-shirt `_. diff --git a/docs/building_your_site/djangodevelopers.rst b/docs/building_your_site/djangodevelopers.rst index b4899d70f..aeabb264f 100644 --- a/docs/building_your_site/djangodevelopers.rst +++ b/docs/building_your_site/djangodevelopers.rst @@ -1,9 +1,15 @@ For Django developers ===================== +.. contents:: Contents + :local: + .. note:: This documentation is currently being written. +Overview +~~~~~~~~ + Wagtail requires a little careful setup to define the types of content that you want to present through your website. The basic unit of content in Wagtail is the ``Page``, and all of your page-level content will inherit basic webpage-related properties from it. But for the most part, you will be defining content yourself, through the construction of Django models using Wagtail's ``Page`` as a base. Wagtail organizes content created from your models in a tree, which can have any structure and combination of model objects in it. Wagtail doesn't prescribe ways to organize and interrelate your content, but here we've sketched out some strategies for organizing your models. @@ -203,7 +209,6 @@ Methods: * get_context * get_template * is_navigable -* get_other_siblings * get_ancestors * get_descendants * get_siblings @@ -269,6 +274,7 @@ not_type(self, model): return self.get_query_set().not_type(model) +.. _wagtail_site_admin: Site ~~~~ @@ -278,3 +284,13 @@ Django's built-in admin interface provides the way to map a "site" (hostname or Access this by going to ``/django-admin/`` and then "Home › Wagtailcore › Sites." To try out a development site, add a single site with the hostname ``localhost`` at port ``8000`` and map it to one of the pieces of content you have created. Wagtail's developers plan to move the site settings into the Wagtail admin interface. + + +.. _redirects: + +Redirects +~~~~~~~~~ + +Wagtail provides a simple interface for creating arbitrary redirects to and from any URL. + +.. image:: ../images/screen_wagtail_redirects.png diff --git a/docs/building_your_site/frontenddevelopers.rst b/docs/building_your_site/frontenddevelopers.rst index 8d0c9a3bb..35aa1c845 100644 --- a/docs/building_your_site/frontenddevelopers.rst +++ b/docs/building_your_site/frontenddevelopers.rst @@ -1,7 +1,8 @@ For Front End developers ======================== -.. contents:: +.. contents:: Contents + :local: ======================== Overview @@ -90,6 +91,9 @@ In addition to Django's standard tags and filters, Wagtail provides some of its Images (tag) ~~~~~~~~~~~~ +.. versionchanged:: 0.4 + The 'image_tags' tags library was renamed to 'wagtailimages_tags' + The ``image`` tag inserts an XHTML-compatible ``img`` element into the page, setting its ``src``, ``width``, ``height`` and ``alt``. See also :ref:`image_tag_alt`. The syntax for the tag is thus:: @@ -100,7 +104,7 @@ For example: .. code-block:: django - {% load image %} + {% load wagtailimages_tags %} ... {% image self.photo width-400 %} @@ -205,7 +209,7 @@ No validation is performed on attributes add in this way by the developer. It's Wagtail can assign the image data to another object using Django's ``as`` syntax: .. code-block:: django - + {% image self.photo width-400 as tmp_photo %} @@ -287,7 +297,7 @@ Takes any ``slug`` as defined in a page's "Promote" tab and returns the URL for .. code-block:: django - {% load slugurl %} + {% load wagtailcore_tags %} ... diff --git a/docs/editing_api.rst b/docs/editing_api.rst index 9f7602b11..5113eb3b8 100644 --- a/docs/editing_api.rst +++ b/docs/editing_api.rst @@ -1,28 +1,26 @@ -Editing API +Defining models with the Editing API =========== .. note:: This documentation is currently being written. - Wagtail provides a highly-customizable editing interface consisting of several components: - * **Fields** — built-in content types to augment the basic types provided by Django. + * **Fields** — built-in content types to augment the basic types provided by Django * **Panels** — the basic editing blocks for fields, groups of fields, and related object clusters * **Choosers** — interfaces for finding related objects in a ForeignKey relationship -Configuring your models to use these components will shape the Wagtail editor to your needs. Wagtail also provides an API for injecting custom CSS and Javascript for further customization, including extending the hallo.js rich text editor. +Configuring your models to use these components will shape the Wagtail editor to your needs. Wagtail also provides an API for injecting custom CSS and JavaScript for further customization, including extending the hallo.js rich text editor. There is also an Edit Handler API for creating your own Wagtail editor components. - Defining Panels ~~~~~~~~~~~~~~~ -A "panel" is the basic editing block in Wagtail. Wagtail will automatically pick the appropriate editing widget for most Django field types, you just need to add a panel for each field you want to show in the Wagtail page editor, in the order you want them to appear. +A "panel" is the basic editing block in Wagtail. Wagtail will automatically pick the appropriate editing widget for most Django field types; implementors just need to add a panel for each field they want to show in the Wagtail page editor, in the order they want them to appear. -There are three types of panels: +There are four basic types of panels: ``FieldPanel( field_name, classname=None )`` This is the panel used for basic Django field types. ``field_name`` is the name of the class property used in your model definition. ``classname`` is a string of optional CSS classes given to the panel which are used in formatting and scripted interactivity. By default, panels are formatted as inset fields. The CSS class ``full`` can be used to format the panel so it covers the full width of the Wagtail page editor. The CSS class ``title`` can be used to mark a field as the source for auto-generated slug strings. @@ -30,10 +28,21 @@ There are three types of panels: ``MultiFieldPanel( children, heading="", classname=None )`` This panel condenses several ``FieldPanel`` s or choosers, from a list or tuple, under a single ``heading`` string. - ``InlinePanel( base_model, relation_name, panels=None, label='', help_text='' )`` + ``InlinePanel( base_model, relation_name, panels=None, classname=None, label='', help_text='' )`` This panel allows for the creation of a "cluster" of related objects over a join to a separate model, such as a list of related links or slides to an image carousel. This is a very powerful, but tricky feature which will take some space to cover, so we'll skip over it for now. For a full explanation on the usage of ``InlinePanel``, see :ref:`inline_panels`. -Wagtail provides a tabbed interface to help organize panels. ``content_panels`` is the main tab, used for the meat of your model content. The other, ``promote_panels``, is suggested for organizing metadata about the content, such as SEO information and other machine-readable information. Since you're writing the panel definitions, you can organize them however you want. + ``FieldRowPanel( children, classname=None)`` + This panel is purely aesthetic. It creates a columnar layout in the editing interface, where each of the child Panels appears alongside each other rather than below. Use of FieldRowPanel particularly helps reduce the "snow-blindness" effect of seeing so many fields on the page, for complex models. It also improves the perceived association between fields of a similar nature. For example if you created a model representing an "Event" which had a starting date and ending date, it may be intuitive to find the start and end date on the same "row". + + FieldRowPanel should be used in combination with ``col*`` classnames added to each of the child Panels of the FieldRowPanel. The Wagtail editing interface is layed out using a grid system, in which the maximum width of the editor is 12 columns wide. Classes ``col1``-``col12`` can be applied to each child of a FieldRowPanel. The class ``col3`` will ensure that field appears 3 columns wide or a quarter the width. ``col4`` would cause the field to be 4 columns wide, or a third the width. + + **(In addition to these four, there are also Chooser Panels, detailed below.)** + +Wagtail provides a tabbed interface to help organize panels. Three such tabs are provided: + +* ``content_panels`` is the main tab, used for the bulk of your model's fields. +* ``promote_panels`` is suggested for organizing fields regarding the promotion of the page around the site and the Internet. For example, a field to dictate whether the page should show in site-wide menus, descriptive text that should appear in site search results, SEO-friendly titles, OpenGraph meta tag content and other machine-readable information. +* ``settings_panels`` is essentially for non-copy fields. By default it contains the page's scheduled publishing fields. Other suggested fields could include a field to switch between one layout/style and another. Let's look at an example of a panel definition: @@ -55,7 +64,10 @@ Let's look at an example of a panel definition: ExamplePage.content_panels = [ FieldPanel('title', classname="full title"), FieldPanel('body', classname="full"), - FieldPanel('date'), + FieldRowPanel([ + FieldPanel('start_date', classname="col3"), + FieldPanel('end_date', classname="col3"), + ]), ImageChooserPanel('splash_image'), DocumentChooserPanel('free_download'), PageChooserPanel('related_page'), @@ -119,7 +131,7 @@ One of the features of Wagtail is a unified image library, which you can access on_delete=models.SET_NULL, related_name='+' ) - + BookPage.content_panels = [ ImageChooserPanel('cover'), # ... @@ -225,7 +237,7 @@ Snippets are vanilla Django models you create yourself without a Wagtail-provide on_delete=models.SET_NULL, related_name='+' ) - + BookPage.content_panels = [ SnippetChooserPanel('advert', Advert), # ... @@ -254,6 +266,12 @@ Titles Use ``classname="title"`` to make Page's built-in title field stand out with more vertical padding. +Col* +------ + +Fields within a ``FieldRowPanel`` can have their width dictated in terms of the number of columns it should span. The ``FieldRowPanel`` is always considered to be 12 columns wide regardless of browser size or the nesting of ``FieldRowPanel`` in any other type of panel. Specify a number of columns thus: ``col3``, ``col4``, ``col6`` etc (up to 12). The resulting width with be *relative* to the full width of the ``FieldRowPanel``. + + Required Fields --------------- @@ -346,9 +364,9 @@ The ``RelatedLink`` class is a vanilla Django abstract model. The ``BookPageRela For another example of using model clusters, see :ref:`tagging` -For more on ``django-modelcluster``, visit `the django-modelcluster github project page`_ ). +For more on ``django-modelcluster``, visit `the django-modelcluster github project page`_. -.. _the django-modelcluster github page: https://github.com/torchbox/django-modelcluster +.. _the django-modelcluster github project page: https://github.com/torchbox/django-modelcluster .. _extending_wysiwyg: @@ -366,11 +384,9 @@ hallo.js plugin names are prefixed with the ``"IKS."`` namespace, but the ``name For information on developing custom hallo.js plugins, see the project's page: https://github.com/bergie/hallo - Edit Handler API ~~~~~~~~~~~~~~~~ - Admin Hooks ----------- @@ -380,7 +396,7 @@ Registering functions with a Wagtail hook follows the following pattern: .. code-block:: python - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks hooks.register('hook', function) @@ -393,7 +409,7 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func .. code-block:: python - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks class UserbarPuppyLinkItem(object): def render(self, request): @@ -414,7 +430,7 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func from django.utils.safestring import mark_safe - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks class WelcomePanel(object): order = 50 @@ -440,7 +456,7 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func from django.http import HttpResponse - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks def do_after_page_create(request, page): return HttpResponse("Congrats on making content!", content_type="text/plain") @@ -468,7 +484,7 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func from django.http import HttpResponse from django.conf.urls import url - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks def admin_view( request ): return HttpResponse( \ @@ -484,13 +500,13 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func .. _construct_main_menu: ``construct_main_menu`` - Add, remove, or alter ``MenuItem`` objects from the Wagtail admin menu. The callable passed to this hook must take a ``request`` object and a list of ``menu_items``; it must return a list of menu items. New items can be constructed from the ``MenuItem`` class by passing in: a ``label`` which will be the text in the menu item, the URL of the admin page you want the menu item to link to (usually by calling ``reverse()`` on the admin view you've set up), CSS class ``name`` applied to the wrapping ``
  • `` of the menu item as ``"menu-{name}"``, CSS ``classnames`` which are used to give the link an icon, and an ``order`` integer which determine's the item's place in the menu. + Add, remove, or alter ``MenuItem`` objects from the Wagtail admin menu. The callable passed to this hook must take a ``request`` object and a list of ``menu_items``; it must return a list of menu items. New items can be constructed from the ``MenuItem`` class by passing in: a ``label`` which will be the text in the menu item, the URL of the admin page you want the menu item to link to (usually by calling ``reverse()`` on the admin view you've set up), CSS class ``name`` applied to the wrapping ``
  • `` of the menu item as ``"menu-{name}"``, CSS ``classnames`` which are used to give the link an icon, and an ``order`` integer which determine's the item's place in the menu. .. code-block:: python from django.core.urlresolvers import reverse - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks from wagtail.wagtailadmin.menu import MenuItem def construct_main_menu(request, menu_items): @@ -510,7 +526,7 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func from django.utils.html import format_html, format_html_join from django.conf import settings - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks def editor_js(): js_files = [ @@ -538,7 +554,7 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func from django.utils.html import format_html from django.conf import settings - from wagtail.wagtailadmin import hooks + from wagtail.wagtailcore import hooks def editor_css(): return format_html('`_ + - `Squid `_ + + +Advanced useage +~~~~~~~~~~~~~~~ + +Purging more than one URL per page +---------------------------------- + +By default, Wagtail will only purge one URL per page. If your page has more than one URL to be purged, you will need to override the ``get_cached_paths`` method on your page type. + +.. code-block:: python + + class BlogIndexPage(Page): + def get_blog_items(self): + # This returns a Django paginator of blog items in this section + return Paginator(self.get_children().live().type(BlogPage), 10) + + def get_cached_paths(self): + # Yield the main URL + yield '/' + + # Yield one URL per page in the paginator to make sure all pages are purged + for page_number in range(1, self.get_blog_items().num_pages): + yield '/?page=' + str(page_number) + + +Purging index pages +------------------- + +Another problem is pages that list other pages (such as a blog index) will not be purged when a blog entry gets added, changed or deleted. You may want to purge the blog index page so the updates are added into the listing quickly. + +This can be solved by using the ``purge_page_from_cache`` utility function which can be found in the ``wagtail.contrib.wagtailfrontendcache.utils`` module. + +Let's take the the above BlogIndexPage as an example. We need to register a signal handler to run when one of the BlogPages get updated/deleted. This signal handler should call the ``purge_page_from_cache`` function on all BlogIndexPages that contain the BlogPage being updated/deleted. + + +.. code-block:: python + + # models.py + from django.db.models.signals import pre_delete + + from wagtail.wagtailcore.signals import page_published + from wagtail.contrib.wagtailfrontendcache.utils import purge_page_from_cache + + + ... + + + def blog_page_changed(blog_page): + # Find all the live BlogIndexPages that contain this blog_page + for blog_index in BlogIndexPage.objects.live(): + if blog_page in blog_index.get_blog_items().object_list: + # Purge this blog index + purge_page_from_cache(blog_index) + + + @register(page_published, sender=BlogPage): + def blog_published_handler(instance): + blog_page_changed(instance) + + + @register(pre_delete, sender=BlogPage) + def blog_deleted_handler(instance): + blog_page_changed(instance) diff --git a/docs/images/screen_wagtail_redirects.png b/docs/images/screen_wagtail_redirects.png new file mode 100644 index 000000000..516bdd046 Binary files /dev/null and b/docs/images/screen_wagtail_redirects.png differ diff --git a/docs/index.rst b/docs/index.rst index abbb7fdbe..ae2300556 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,12 +3,13 @@ Welcome to Wagtail's documentation Wagtail is a modern, flexible CMS, built on Django. -It supports Django 1.6.2+ on Python 2.6 and 2.7. Django 1.7 and Python 3 support are in progress. +It supports Django 1.6.2+ on Python 2.6, 2.7, 3.2, 3.3 and 3.4. Django 1.7 support is in progress pending further release candidate testing. .. toctree:: :maxdepth: 3 gettingstarted + settings building_your_site/index editing_api snippets @@ -19,6 +20,7 @@ It supports Django 1.6.2+ on Python 2.6 and 2.7. Django 1.7 and Python 3 support deploying performance static_site_generation + management_commands contributing support roadmap diff --git a/docs/management_commands.rst b/docs/management_commands.rst new file mode 100644 index 000000000..3f77fc3ef --- /dev/null +++ b/docs/management_commands.rst @@ -0,0 +1,52 @@ +Management commands +=================== + +publish_scheduled_pages +----------------------- + +:code:`./manage.py publish_scheduled_pages` + +This command publishes or unpublishes pages that have had these actions scheduled by an editor. It is recommended to run this command once an hour. + +fixtree +------- + +:code:`./manage.py fixtree` + +This command scans for errors in your database and attempts to fix any issues it finds. + +move_pages +---------- + +:code:`manage.py move_pages from to` + +This command moves a selection of pages from one section of the tree to another. + +Options: + + - **from** + This is the **id** of the page to move pages from. All descendants of this page will be moved to the destination. After the operation is complete, this page will have no children. + + - **to** + This is the **id** of the page to move pages to. + +update_index +------------ + +:code:`./manage.py update_index` + +This command rebuilds the search index from scratch. It is only required when using Elasticsearch. + +It is recommended to run this command once a week and at the following times: + + - whenever any pages have been created through a script (after an import, for example) + - whenever any changes have been made to models or search configuration + +The search may not return any results while this command is running, so avoid running it at peak times. + +search_garbage_collect +---------------------- + +:code:`./manage.py search_garbage_collect` + +Wagtail keeps a log of search queries that are popular on your website. On high traffic websites, this log may get big and you may want to clean out old search queries. This command cleans out all search query logs that are more than one week old. diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 000000000..018af5d01 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,603 @@ + +============================== +Configuring Django for Wagtail +============================== + +To install Wagtail completely from scratch, create a new Django project and an app within that project. For instructions on these tasks, see `Writing your first Django app`_. Your project directory will look like the following:: + + myproject/ + myproject/ + __init__.py + settings.py + urls.py + wsgi.py + myapp/ + __init__.py + models.py + tests.py + admin.py + views.py + manage.py + +From your app directory, you can safely remove ``admin.py`` and ``views.py``, since Wagtail will provide this functionality for your models. Configuring Django to load Wagtail involves adding modules and variables to ``settings.py`` and urlconfs to ``urls.py``. For a more complete view of what's defined in these files, see `Django Settings`_ and `Django URL Dispatcher`_. + +.. _Writing your first Django app: https://docs.djangoproject.com/en/dev/intro/tutorial01/ + +.. _Django Settings: https://docs.djangoproject.com/en/dev/topics/settings/ + +.. _Django URL Dispatcher: https://docs.djangoproject.com/en/dev/topics/http/urls/ + +What follows is a settings reference which skips many boilerplate Django settings. If you just want to get your Wagtail install up quickly without fussing with settings at the moment, see :ref:`complete_example_config`. + + +Middleware (settings.py) +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + 'wagtail.wagtailcore.middleware.SiteMiddleware', + + 'wagtail.wagtailredirects.middleware.RedirectMiddleware', + ) + +Wagtail requires several common Django middleware modules to work and cover basic security. Wagtail provides its own middleware to cover these tasks: + +``SiteMiddleware`` + Wagtail routes pre-defined hosts to pages within the Wagtail tree using this middleware. For configuring sites, see :ref:`wagtail_site_admin`. + +``RedirectMiddleware`` + Wagtail provides a simple interface for adding arbitrary redirects to your site and this module makes it happen. + + +Apps (settings.py) +~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'south', + 'compressor', + 'taggit', + 'modelcluster', + 'django.contrib.admin', + + 'wagtail.wagtailcore', + 'wagtail.wagtailadmin', + 'wagtail.wagtaildocs', + 'wagtail.wagtailsnippets', + 'wagtail.wagtailusers', + 'wagtail.wagtailimages', + 'wagtail.wagtailembeds', + 'wagtail.wagtailsearch', + 'wagtail.wagtailredirects', + 'wagtail.wagtailforms', + + 'myapp', # your own app + ) + +Wagtail requires several Django app modules, third-party apps, and defines several apps of its own. Wagtail was built to be modular, so many Wagtail apps can be omitted to suit your needs. Your own app (here ``myapp``) is where you define your models, templates, static assets, template tags, and other custom functionality for your site. + + +Third-Party Apps +---------------- + +``south`` + Used for database migrations. See `South Documentation`_. + +.. _South Documentation: http://south.readthedocs.org/en/latest/ + +``compressor`` + Static asset combiner and minifier for Django. Compressor also enables for the use of preprocessors. See `Compressor Documentation`_. + +.. _Compressor Documentation: http://django-compressor.readthedocs.org/en/latest/ + +``taggit`` + Tagging framework for Django. This is used internally within Wagtail for image and document tagging and is available for your own models as well. See :ref:`tagging` for a Wagtail model recipe or the `Taggit Documentation`_. + +.. _Taggit Documentation: http://django-taggit.readthedocs.org/en/latest/index.html + +``modelcluster`` + Extension of Django ForeignKey relation functionality, which is used in Wagtail pages for on-the-fly related object creation. For more information, see :ref:`inline_panels` or `the django-modelcluster github project page`_. + +.. _the django-modelcluster github project page: https://github.com/torchbox/django-modelcluster + +``django.contrib.admin`` + The Django admin module. While Wagtail will eventually provide a sites-editing interface, the Django admin is included for now to provide that functionality. + + +Wagtail Apps +------------ + +``wagtailcore`` + The core functionality of Wagtail, such as the ``Page`` class, the Wagtail tree, and model fields. + +``wagtailadmin`` + The administration interface for Wagtail, including page edit handlers. + +``wagtaildocs`` + The Wagtail document content type. + +``wagtailsnippets`` + Editing interface for non-Page models and objects. See :ref:`Snippets`. + +``wagtailusers`` + User editing interface. + +``wagtailimages`` + The Wagtail image content type. + +``wagtailembeds`` + Module governing oEmbed and Embedly content in Wagtail rich text fields. See :ref:`inserting_videos`. + +``wagtailsearch`` + Search framework for Page content. See :ref:`search`. + +``wagtailredirects`` + Admin interface for creating arbitrary redirects on your site. See :ref:`redirects`. + +``wagtailforms`` + Models for creating forms on your pages and viewing submissions. See :ref:`form_builder`. + + +Settings Variables (settings.py) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Authentication +-------------- + +.. code-block:: python + + LOGIN_URL = 'wagtailadmin_login' + LOGIN_REDIRECT_URL = 'wagtailadmin_home' + +These settings variables set the Django authentication system to redirect to the Wagtail admin login. If you plan to use the Django authentication module to log in non-privileged users, you should set these variables to your own login views. See `Django User Authentication`_. + +.. _Django User Authentication: https://docs.djangoproject.com/en/dev/topics/auth/ + + +Site Name +--------- + +.. code-block:: python + + WAGTAIL_SITE_NAME = 'Stark Industries Skunkworks' + +This is the human-readable name of your Wagtail install which welcomes users upon login to the Wagtail admin. + + +Search +------ + +.. code-block:: python + + # Override the search results template for wagtailsearch + WAGTAILSEARCH_RESULTS_TEMPLATE = 'myapp/search_results.html' + WAGTAILSEARCH_RESULTS_TEMPLATE_AJAX = 'myapp/includes/search_listing.html' + + # Replace the search backend + WAGTAILSEARCH_BACKENDS = { + 'default': { + 'BACKEND': 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch', + 'INDEX': 'myapp' + } + } + +The search settings customize the search results templates as well as choosing a custom backend for search. For a full explanation, see :ref:`search`. + + +Embeds +------ + +Wagtail uses the oEmbed standard with a large but not comprehensive number of "providers" (youtube, vimeo, etc.). You can also use a different embed backend by providing an Embedly key or replacing the embed backend by writing your own embed finder function. + +.. code-block:: python + + WAGTAILEMBEDS_EMBED_FINDER = 'myapp.embeds.my_embed_finder_function' + +Use a custom embed finder function, which takes a URL and returns a dict with metadata and embeddable HTML. Refer to the ``wagtail.wagtailemebds.embeds`` module source for more information and examples. + +.. code-block:: python + + # not a working key, get your own! + EMBEDLY_KEY = '253e433d59dc4d2xa266e9e0de0cb830' + +Providing an API key for the Embedly service will use that as a embed backend, with a more extensive list of providers, as well as analytics and other features. For more information, see `Embedly`_. + +.. _Embedly: http://embed.ly/ + +To use Embedly, you must also install their python module: + +.. code-block:: bash + + $ pip install embedly + + +Images +------ + +.. code-block:: python + + WAGTAILIMAGES_IMAGE_MODEL = 'myapp.MyImage' + +This setting lets you provide your own image model for use in Wagtail, which might extend the built-in ``AbstractImage`` class or replace it entirely. + + +Email Notifications +------------------- + +.. code-block:: python + + WAGTAILADMIN_NOTIFICATION_FROM_EMAIL = 'wagtail@myhost.io' + +Wagtail sends email notifications when content is submitted for moderation, and when the content is accepted or rejected. This setting lets you pick which email address these automatic notifications will come from. If omitted, Django will fall back to using the ``DEFAULT_FROM_EMAIL`` variable if set, and ``webmaster@localhost`` if not. + + +Other Django Settings Used by Wagtail +------------------------------------- + +.. code-block:: python + + ALLOWED_HOSTS + APPEND_SLASH + AUTH_USER_MODEL + BASE_URL + CACHES + DEFAULT_FROM_EMAIL + INSTALLED_APPS + MEDIA_ROOT + SESSION_COOKIE_DOMAIN + SESSION_COOKIE_NAME + SESSION_COOKIE_PATH + STATIC_URL + TEMPLATE_CONTEXT_PROCESSORS + USE_I18N + +For information on what these settings do, see `Django Settings`_. + +.. _Django Settings: https://docs.djangoproject.com/en/dev/ref/settings/ + + +Search Signal Handlers +---------------------- + +.. code-block:: python + + from wagtail.wagtailsearch import register_signal_handlers as wagtailsearch_register_signal_handlers + + wagtailsearch_register_signal_handlers() + +This loads Wagtail's search signal handlers, which need to be loaded very early in the Django life cycle. While not technically a urlconf, this is a convenient place to load them. Calling this function registers signal handlers to watch for when indexed models get saved or deleted. This allows wagtailsearch to update ElasticSearch automatically. + + +URL Patterns +------------ + +.. code-block:: python + + from django.contrib import admin + + from wagtail.wagtailcore import urls as wagtail_urls + from wagtail.wagtailadmin import urls as wagtailadmin_urls + from wagtail.wagtaildocs import urls as wagtaildocs_urls + from wagtail.wagtailsearch.urls import frontend as wagtailsearch_frontend_urls + + admin.autodiscover() + + urlpatterns = patterns('', + url(r'^django-admin/', include(admin.site.urls)), + + url(r'^admin/', include(wagtailadmin_urls)), + url(r'^search/', include(wagtailsearch_frontend_urls)), + url(r'^documents/', include(wagtaildocs_urls)), + + # Optional urlconf for including your own vanilla Django urls/views + url(r'', include('myapp.urls')), + + # For anything not caught by a more specific rule above, hand over to + # Wagtail's serving mechanism + url(r'', include(wagtail_urls)), + ) + +This block of code for your project's ``urls.py`` does a few things: + +* Load the vanilla Django admin interface to ``/django-admin/`` +* Load the Wagtail admin and its various apps +* Dispatch any vanilla Django apps you're using other than Wagtail which require their own urlconfs (this is optional, since Wagtail might be all you need) +* Lets Wagtail handle any further URL dispatching. + +That's not everything you might want to include in your project's urlconf, but it's what's necessary for Wagtail to flourish. + + +.. _complete_example_config: + +Ready to Use Example Config Files +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These two files should reside in your project directory (``myproject/myproject/``). + + +settings.py +----------- + +.. code-block:: python + + import os + + PROJECT_ROOT = os.path.join(os.path.dirname(__file__), '..', '..') + + DEBUG = True + TEMPLATE_DEBUG = DEBUG + + ADMINS = ( + # ('Your Name', 'your_email@example.com'), + ) + + MANAGERS = ADMINS + + # Default to dummy email backend. Configure dev/production/local backend + # as per https://docs.djangoproject.com/en/dev/topics/email/#email-backends + EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend' + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'myprojectdb', + 'USER': 'postgres', + 'PASSWORD': '', + 'HOST': '', # Set to empty string for localhost. + 'PORT': '', # Set to empty string for default. + 'CONN_MAX_AGE': 600, # number of seconds database connections should persist for + } + } + + # Hosts/domain names that are valid for this site; required if DEBUG is False + # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts + ALLOWED_HOSTS = [] + + # Local time zone for this installation. Choices can be found here: + # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name + # although not all choices may be available on all operating systems. + # On Unix systems, a value of None will cause Django to use the same + # timezone as the operating system. + # If running in a Windows environment this must be set to the same as your + # system time zone. + TIME_ZONE = 'Europe/London' + + # Language code for this installation. All choices can be found here: + # http://www.i18nguy.com/unicode/language-identifiers.html + LANGUAGE_CODE = 'en-gb' + + SITE_ID = 1 + + # If you set this to False, Django will make some optimizations so as not + # to load the internationalization machinery. + USE_I18N = True + + # If you set this to False, Django will not format dates, numbers and + # calendars according to the current locale. + # Note that with this set to True, Wagtail will fall back on using numeric dates + # in date fields, as opposed to 'friendly' dates like "24 Sep 2013", because + # Python's strptime doesn't support localised month names: https://code.djangoproject.com/ticket/13339 + USE_L10N = False + + # If you set this to False, Django will not use timezone-aware datetimes. + USE_TZ = True + + # Absolute filesystem path to the directory that will hold user-uploaded files. + # Example: "/home/media/media.lawrence.com/media/" + MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'media') + + # URL that handles the media served from MEDIA_ROOT. Make sure to use a + # trailing slash. + # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" + MEDIA_URL = '/media/' + + # Absolute path to the directory static files should be collected to. + # Don't put anything in this directory yourself; store your static files + # in apps' "static/" subdirectories and in STATICFILES_DIRS. + # Example: "/home/media/media.lawrence.com/static/" + STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') + + # URL prefix for static files. + # Example: "http://media.lawrence.com/static/" + STATIC_URL = '/static/' + + # List of finder classes that know how to find static files in + # various locations. + STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'compressor.finders.CompressorFinder', + ) + + # Make this unique, and don't share it with anybody. + SECRET_KEY = 'change-me' + + # List of callables that know how to import templates from various sources. + TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ) + + MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + 'wagtail.wagtailcore.middleware.SiteMiddleware', + + 'wagtail.wagtailredirects.middleware.RedirectMiddleware', + ) + + from django.conf import global_settings + TEMPLATE_CONTEXT_PROCESSORS = global_settings.TEMPLATE_CONTEXT_PROCESSORS + ( + 'django.core.context_processors.request', + ) + + ROOT_URLCONF = 'myproject.urls' + + # Python dotted path to the WSGI application used by Django's runserver. + WSGI_APPLICATION = 'wagtaildemo.wsgi.application' + + INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'south', + 'compressor', + 'taggit', + 'modelcluster', + 'django.contrib.admin', + + 'wagtail.wagtailcore', + 'wagtail.wagtailadmin', + 'wagtail.wagtaildocs', + 'wagtail.wagtailsnippets', + 'wagtail.wagtailusers', + 'wagtail.wagtailimages', + 'wagtail.wagtailembeds', + 'wagtail.wagtailsearch', + 'wagtail.wagtailredirects', + 'wagtail.wagtailforms', + + 'myapp', + ) + + EMAIL_SUBJECT_PREFIX = '[Wagtail] ' + + INTERNAL_IPS = ('127.0.0.1', '10.0.2.2') + + # django-compressor settings + COMPRESS_PRECOMPILERS = ( + ('text/x-scss', 'django_libsass.SassCompiler'), + ) + + # Auth settings + LOGIN_URL = 'wagtailadmin_login' + LOGIN_REDIRECT_URL = 'wagtailadmin_home' + + # A sample logging configuration. The only tangible logging + # performed by this configuration is to send an email to + # the site admins on every HTTP 500 error when DEBUG=False. + # See http://docs.djangoproject.com/en/dev/topics/logging for + # more details on how to customize your logging configuration. + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + } + }, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } + } + + + # WAGTAIL SETTINGS + + # This is the human-readable name of your Wagtail install + # which welcomes users upon login to the Wagtail admin. + WAGTAIL_SITE_NAME = 'My Project' + + # Override the search results template for wagtailsearch + # WAGTAILSEARCH_RESULTS_TEMPLATE = 'myapp/search_results.html' + # WAGTAILSEARCH_RESULTS_TEMPLATE_AJAX = 'myapp/includes/search_listing.html' + + # Replace the search backend + #WAGTAILSEARCH_BACKENDS = { + # 'default': { + # 'BACKEND': 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch', + # 'INDEX': 'myapp' + # } + #} + + # Wagtail email notifications from address + # WAGTAILADMIN_NOTIFICATION_FROM_EMAIL = 'wagtail@myhost.io' + + # If you want to use Embedly for embeds, supply a key + # (this key doesn't work, get your own!) + # EMBEDLY_KEY = '253e433d59dc4d2xa266e9e0de0cb830' + + +urls.py +------- + +.. code-block:: python + + from django.conf.urls import patterns, include, url + from django.conf.urls.static import static + from django.views.generic.base import RedirectView + from django.contrib import admin + from django.conf import settings + import os.path + + from wagtail.wagtailcore import urls as wagtail_urls + from wagtail.wagtailadmin import urls as wagtailadmin_urls + from wagtail.wagtaildocs import urls as wagtaildocs_urls + from wagtail.wagtailsearch.urls import frontend as wagtailsearch_frontend_urls + + admin.autodiscover() + + + # Signal handlers + from wagtail.wagtailsearch import register_signal_handlers as wagtailsearch_register_signal_handlers + wagtailsearch_register_signal_handlers() + + + urlpatterns = patterns('', + url(r'^django-admin/', include(admin.site.urls)), + + url(r'^admin/', include(wagtailadmin_urls)), + url(r'^search/', include(wagtailsearch_frontend_urls)), + url(r'^documents/', include(wagtaildocs_urls)), + + # For anything not caught by a more specific rule above, hand over to + # Wagtail's serving mechanism + url(r'', include(wagtail_urls)), + ) + + + if settings.DEBUG: + from django.contrib.staticfiles.urls import staticfiles_urlpatterns + + urlpatterns += staticfiles_urlpatterns() # tell gunicorn where static files are in dev mode + urlpatterns += static(settings.MEDIA_URL + 'images/', document_root=os.path.join(settings.MEDIA_ROOT, 'images')) + urlpatterns += patterns('', + (r'^favicon\.ico$', RedirectView.as_view(url=settings.STATIC_URL + 'myapp/images/favicon.ico')) + ) + + + diff --git a/docs/sitemap_generation.rst b/docs/sitemap_generation.rst new file mode 100644 index 000000000..9c6d50e48 --- /dev/null +++ b/docs/sitemap_generation.rst @@ -0,0 +1,58 @@ +Sitemap generation +================== + +This document describes how to create XML sitemaps for your Wagtail website using the ``wagtail.contrib.wagtailsitemaps`` module. + + +Basic configuration +~~~~~~~~~~~~~~~~~~~ + +You firstly need to add ``"wagtail.contrib.wagtailsitemaps"`` to INSTALLED_APPS in your Django settings file: + + .. code-block:: python + + INSTALLED_APPS = [ + ... + + "wagtail.contrib.wagtailsitemaps", + ] + + +Then, in urls.py, you need to add a link to the ``wagtail.contrib.wagtailsitemaps.views.sitemap`` view which generates the sitemap: + +.. code-block:: python + + from wagtail.contrib.wagtailsitemaps.views import sitemap + + urlpatterns = patterns('', + ... + + url('^sitemap\.xml$', sitemap), + ) + + +You should now be able to browse to "/sitemap.xml" and see the sitemap working. By default, all published pages in your website will be added to the site map. + + +Customising +~~~~~~~~~~~ + +URLs +---- + +The Page class defines a ``get_sitemap_urls`` method which you can override to customise sitemaps per page instance. This method must return a list of dictionaries, one dictionary per URL entry in the sitemap. You can exclude pages from the sitemap by returning an empty list. + +Each dictionary can contain the following: + + - **location** (required) - This is the full URL path to add into the sitemap. + - **lastmod** - A python date or datetime set to when the page was last modified. + - **changefreq** + - **priority** + +You can add more but you will need to override the ``wagtailsitemaps/sitemap.xml`` template in order for them to be displayed in the sitemap. + + +Cache +----- + +By default, sitemaps are cached for 100 minutes. You can change this by setting ``WAGTAILSITEMAPS_CACHE_TIMEOUT`` in your Django settings to the number of seconds you would like the cache to last for. diff --git a/docs/static_site_generation.rst b/docs/static_site_generation.rst index a9379cd67..9feb59c36 100644 --- a/docs/static_site_generation.rst +++ b/docs/static_site_generation.rst @@ -25,8 +25,19 @@ Then add ``django_medusa`` and ``wagtail.contrib.wagtailmedusa`` to ``INSTALLED_ ] +Rendering +~~~~~~~~~ + +To render a site, run ``./manage.py staticsitegen``. This will render the entire website and place the HTML in a folder called 'medusa_output'. The static and media folders need to be copied into this folder manually after the rendering is complete. This feature inherits django-medusa's ability to render your static site to Amazon S3 or Google App Engine; see the `medusa docs `_ for configuration details. + +To test, open the 'medusa_output' folder in a terminal and run ``python -m SimpleHTTPServer``. + + +Advanced topics +~~~~~~~~~~~~~~~ + Replacing GET parameters with custom routing -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +-------------------------------------------- Pages which require GET parameters (e.g. for pagination) don't generate suitable filenames for generated HTML files so they need to be changed to use custom routing instead. @@ -51,7 +62,7 @@ For example, let's say we have a Blog Index which uses pagination. We can overri Rendering pages which use custom routing -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +---------------------------------------- For page types that override the ``route`` method, we need to let django medusa know which URLs it responds on. This is done by overriding the ``get_static_site_paths`` method to make it yield one string per URL path. @@ -72,12 +83,4 @@ For example, the BlogIndex above would need to yield one URL for each page of re yield path -Rendering -~~~~~~~~~ - -To render a site, run ``./manage.py staticsitegen``. This will render the entire website and place the HTML in a folder called 'medusa_output'. The static and media folders need to be copied into this folder manually after the rendering is complete. This feature inherits django-medusa's ability to render your static site to Amazon S3 or Google App Engine; see the `medusa docs `_ for configuration details. - -To test, open the 'medusa_output' folder in a terminal and run ``python -m SimpleHTTPServer``. - - .. _django medusa: https://github.com/mtigas/django-medusa diff --git a/docs/wagtail_search.rst b/docs/wagtail_search.rst index 1fa2c6525..6a77b06f3 100644 --- a/docs/wagtail_search.rst +++ b/docs/wagtail_search.rst @@ -1,3 +1,6 @@ + +.. _search: + Search ====== diff --git a/requirements-dev.txt b/requirements-dev.txt index 4a9ff5c5e..b69c5d4e0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ # For coverage and PEP8 linting coverage==3.7.1 -flake8==2.1.0 +flake8==2.2.1 mock==1.0.1 diff --git a/runtests.py b/runtests.py index efa66910f..20b4512fa 100755 --- a/runtests.py +++ b/runtests.py @@ -43,6 +43,7 @@ if not settings.configured: STATIC_ROOT=STATIC_ROOT, MEDIA_ROOT=MEDIA_ROOT, USE_TZ=True, + TIME_ZONE='UTC', STATICFILES_FINDERS=( 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'compressor.finders.CompressorFinder', @@ -85,6 +86,7 @@ if not settings.configured: 'wagtail.wagtailredirects', 'wagtail.wagtailforms', 'wagtail.contrib.wagtailstyleguide', + 'wagtail.contrib.wagtailsitemaps', 'wagtail.tests', ], diff --git a/setup.py b/setup.py index 7b26817cd..6e0bc6949 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,8 @@ #!/usr/bin/env python +import sys + + try: from setuptools import setup, find_packages except ImportError: @@ -16,6 +19,32 @@ except ImportError: pass +PY3 = sys.version_info[0] == 3 + + +install_requires = [ + "Django>=1.6.2,<1.7", + "South>=0.8.4", + "django-compressor>=1.3", + "django-libsass>=0.1", + "django-modelcluster>=0.1", + "django-taggit==0.11.2", + "django-treebeard==2.0", + "Pillow>=2.3.0", + "beautifulsoup4>=4.3.2", + "lxml>=3.3.0", + "Unidecode>=0.04.14", + "six==1.7.3", + 'requests==2.3.0', +] + + +if not PY3: + install_requires += [ + "unicodecsv>=0.9.4" + ] + + setup( name='wagtail', version='0.3.1', @@ -37,23 +66,13 @@ setup( 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', 'Framework :: Django', 'Topic :: Internet :: WWW/HTTP :: Site Management', ], - install_requires=[ - "Django>=1.6.2,<1.7", - "South>=0.8.4", - "django-compressor>=1.3", - "django-libsass>=0.1", - "django-modelcluster>=0.1", - "django-taggit==0.11.2", - "django-treebeard==2.0", - "Pillow>=2.3.0", - "beautifulsoup4>=4.3.2", - "lxml>=3.3.0", - 'unicodecsv>=0.9.4', - 'Unidecode>=0.04.14', - "BeautifulSoup==3.2.1", # django-compressor gets confused if we have lxml but not BS3 installed - ], + install_requires=install_requires, zip_safe=False, ) diff --git a/tox.ini b/tox.ini index 11b8bb3a2..2b17c65ba 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,18 @@ [deps] dj16= Django>=1.6,<1.7 - pyelasticsearch==0.6.1 - elasticutils==0.8.2 + elasticsearch==1.1.0 + mock==1.0.1 [tox] envlist = py26-dj16-postgres, py26-dj16-sqlite, py27-dj16-postgres, - py27-dj16-sqlite + py27-dj16-sqlite, + py32-dj16-postgres, + py33-dj16-postgres, + py34-dj16-postgres # mysql not currently supported # (wagtail.wagtailimages.tests.TestImageEditView currently fails with a @@ -17,6 +20,12 @@ envlist = # py26-dj16-mysql # py27-dj16-mysql +# South fails with sqlite on python3, because it tries to use DryRunMigrator which uses iteritems +# py32-dj16-sqlite, +# py33-dj16-sqlite, +# py34-dj16-sqlite + + [testenv] commands=./runtests.py @@ -24,7 +33,7 @@ commands=./runtests.py basepython=python2.6 deps = {[deps]dj16} - psycopg2==2.5.2 + psycopg2==2.5.3 setenv = DATABASE_ENGINE=django.db.backends.postgresql_psycopg2 @@ -48,7 +57,7 @@ setenv = basepython=python2.7 deps = {[deps]dj16} - psycopg2==2.5.2 + psycopg2==2.5.3 setenv = DATABASE_ENGINE=django.db.backends.postgresql_psycopg2 @@ -67,3 +76,48 @@ deps = setenv = DATABASE_ENGINE=django.db.backends.mysql DATABASE_USER=wagtail + +[testenv:py32-dj16-postgres] +basepython=python3.2 +deps = + {[deps]dj16} + psycopg2==2.5.3 +setenv = + DATABASE_ENGINE=django.db.backends.postgresql_psycopg2 + +[testenv:py32-dj16-sqlite] +basepython=python3.2 +deps = + {[deps]dj16} +setenv = + DATABASE_ENGINE=django.db.backends.sqlite3 + +[testenv:py33-dj16-postgres] +basepython=python3.3 +deps = + {[deps]dj16} + psycopg2==2.5.3 +setenv = + DATABASE_ENGINE=django.db.backends.postgresql_psycopg2 + +[testenv:py33-dj16-sqlite] +basepython=python3.3 +deps = + {[deps]dj16} +setenv = + DATABASE_ENGINE=django.db.backends.sqlite3 + +[testenv:py34-dj16-postgres] +basepython=python3.4 +deps = + {[deps]dj16} + psycopg2==2.5.3 +setenv = + DATABASE_ENGINE=django.db.backends.postgresql_psycopg2 + +[testenv:py34-dj16-sqlite] +basepython=python3.4 +deps = + {[deps]dj16} +setenv = + DATABASE_ENGINE=django.db.backends.sqlite3 diff --git a/wagtail/contrib/wagtailfrontendcache/__init__.py b/wagtail/contrib/wagtailfrontendcache/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/wagtailfrontendcache/models.py b/wagtail/contrib/wagtailfrontendcache/models.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/wagtailfrontendcache/signal_handlers.py b/wagtail/contrib/wagtailfrontendcache/signal_handlers.py new file mode 100644 index 000000000..0fc92d46e --- /dev/null +++ b/wagtail/contrib/wagtailfrontendcache/signal_handlers.py @@ -0,0 +1,25 @@ +from django.db import models +from django.db.models.signals import post_delete + +from wagtail.wagtailcore.models import Page +from wagtail.wagtailcore.signals import page_published + +from wagtail.contrib.wagtailfrontendcache.utils import purge_page_from_cache + + +def page_published_signal_handler(instance, **kwargs): + purge_page_from_cache(instance) + + +def post_delete_signal_handler(instance, **kwargs): + purge_page_from_cache(instance) + + +def register_signal_handlers(): + # Get list of models that are page types + indexed_models = [model for model in models.get_models() if issubclass(model, Page)] + + # Loop through list and register signal handlers for each one + for model in indexed_models: + page_published.connect(page_published_signal_handler, sender=model) + post_delete.connect(post_delete_signal_handler, sender=model) diff --git a/wagtail/contrib/wagtailfrontendcache/utils.py b/wagtail/contrib/wagtailfrontendcache/utils.py new file mode 100644 index 000000000..b95ed7e95 --- /dev/null +++ b/wagtail/contrib/wagtailfrontendcache/utils.py @@ -0,0 +1,35 @@ +import requests +from requests.adapters import HTTPAdapter + +from django.conf import settings + + +class CustomHTTPAdapter(HTTPAdapter): + """ + Requests will always send requests to whatever server is in the netloc + part of the URL. This is a problem with purging the cache as this netloc + may point to a different server (such as an nginx instance running in + front of the cache). + + This class allows us to send a purge request directly to the cache server + with the host header still set correctly. It does this by changing the "url" + parameter of get_connection to always point to the cache server. Requests + will then use this connection to purge the page. + """ + def __init__(self, cache_url): + self.cache_url = cache_url + super(CustomHTTPAdapter, self).__init__() + + def get_connection(self, url, proxies=None): + return super(CustomHTTPAdapter, self).get_connection(self.cache_url, proxies) + + +def purge_page_from_cache(page): + # Get session + cache_server_url = getattr(settings, 'WAGTAILFRONTENDCACHE_LOCATION', 'http://127.0.0.1:8000/') + session = requests.Session() + session.mount('http://', CustomHTTPAdapter(cache_server_url)) + + # Purge paths from cache + for path in page.get_cached_paths(): + session.request('PURGE', page.full_url + path[1:]) diff --git a/wagtail/contrib/wagtailsitemaps/__init__.py b/wagtail/contrib/wagtailsitemaps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/wagtailsitemaps/models.py b/wagtail/contrib/wagtailsitemaps/models.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/wagtailsitemaps/sitemap_generator.py b/wagtail/contrib/wagtailsitemaps/sitemap_generator.py new file mode 100644 index 000000000..eb08d81b7 --- /dev/null +++ b/wagtail/contrib/wagtailsitemaps/sitemap_generator.py @@ -0,0 +1,21 @@ +from django.template.loader import render_to_string + + +class Sitemap(object): + template = 'wagtailsitemaps/sitemap.xml' + + def __init__(self, site): + self.site = site + + def get_pages(self): + return self.site.root_page.get_descendants(inclusive=True).live().order_by('path') + + def get_urls(self): + for page in self.get_pages(): + for url in page.get_sitemap_urls(): + yield url + + def render(self): + return render_to_string(self.template, { + 'urlset': self.get_urls() + }) diff --git a/wagtail/contrib/wagtailsitemaps/templates/wagtailsitemaps/sitemap.xml b/wagtail/contrib/wagtailsitemaps/templates/wagtailsitemaps/sitemap.xml new file mode 100644 index 000000000..30ca3c024 --- /dev/null +++ b/wagtail/contrib/wagtailsitemaps/templates/wagtailsitemaps/sitemap.xml @@ -0,0 +1,13 @@ + + +{% spaceless %} +{% for url in urlset %} + + {{ url.location }} + {% if url.lastmod %}{{ url.lastmod|date:"Y-m-d" }}{% endif %} + {% if url.changefreq %}{{ url.changefreq }}{% endif %} + {% if url.priority %}{{ url.priority }}{% endif %} + +{% endfor %} +{% endspaceless %} + diff --git a/wagtail/contrib/wagtailsitemaps/tests.py b/wagtail/contrib/wagtailsitemaps/tests.py new file mode 100644 index 000000000..d2d612fa2 --- /dev/null +++ b/wagtail/contrib/wagtailsitemaps/tests.py @@ -0,0 +1,83 @@ +from django.test import TestCase +from django.core.cache import cache + +from wagtail.wagtailcore.models import Page, Site +from wagtail.tests.models import SimplePage + +from .sitemap_generator import Sitemap + + +class TestSitemapGenerator(TestCase): + def setUp(self): + self.home_page = Page.objects.get(id=2) + + self.child_page = self.home_page.add_child(instance=SimplePage( + title="Hello world!", + slug='hello-world', + live=True, + )) + + self.unpublished_child_page = self.home_page.add_child(instance=SimplePage( + title="Unpublished", + slug='unpublished', + live=False, + )) + + self.site = Site.objects.get(is_default_site=True) + + def test_get_pages(self): + sitemap = Sitemap(self.site) + pages = sitemap.get_pages() + + self.assertIn(self.child_page.page_ptr, pages) + self.assertNotIn(self.unpublished_child_page.page_ptr, pages) + + def test_get_urls(self): + sitemap = Sitemap(self.site) + urls = [url['location'] for url in sitemap.get_urls()] + + self.assertIn('/', urls) # Homepage + self.assertIn('/hello-world/', urls) # Child page + + def test_render(self): + sitemap = Sitemap(self.site) + xml = sitemap.render() + + # Check that a URL has made it into the xml + self.assertIn('/hello-world/', xml) + + # Make sure the unpublished page didn't make it into the xml + self.assertNotIn('/unpublished/', xml) + + +class TestSitemapView(TestCase): + def test_sitemap_view(self): + response = self.client.get('/sitemap.xml') + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'wagtailsitemaps/sitemap.xml') + self.assertEqual(response['Content-Type'], 'text/xml; charset=utf-8') + + def test_sitemap_view_cache(self): + cache_key = 'wagtail-sitemap:%d' % Site.objects.get(is_default_site=True).id + + # Check that the key is not in the cache + self.assertFalse(cache.has_key(cache_key)) + + # Hit the view + first_response = self.client.get('/sitemap.xml') + + self.assertEqual(first_response.status_code, 200) + self.assertTemplateUsed(first_response, 'wagtailsitemaps/sitemap.xml') + + # Check that the key is in the cache + self.assertTrue(cache.has_key(cache_key)) + + # Hit the view again. Should come from the cache this time + second_response = self.client.get('/sitemap.xml') + + self.assertEqual(second_response.status_code, 200) + self.assertTemplateNotUsed(second_response, 'wagtailsitemaps/sitemap.xml') # Sitemap should not be re rendered + + # Check that the content is the same + self.assertEqual(first_response.content, second_response.content) diff --git a/wagtail/contrib/wagtailsitemaps/views.py b/wagtail/contrib/wagtailsitemaps/views.py new file mode 100644 index 000000000..04f31fdae --- /dev/null +++ b/wagtail/contrib/wagtailsitemaps/views.py @@ -0,0 +1,23 @@ +from django.http import HttpResponse +from django.core.cache import cache +from django.conf import settings + +from .sitemap_generator import Sitemap + + +def sitemap(request): + cache_key = 'wagtail-sitemap:' + str(request.site.id) + sitemap_xml = cache.get(cache_key) + + if not sitemap_xml: + # Rerender sitemap + sitemap = Sitemap(request.site) + sitemap_xml = sitemap.render() + + cache.set(cache_key, sitemap_xml, getattr(settings, 'WAGTAILSITEMAPS_CACHE_TIMEOUT', 6000)) + + # Build response + response = HttpResponse(sitemap_xml) + response['Content-Type'] = "text/xml; charset=utf-8" + + return response diff --git a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html index e7bd88606..8506e9075 100644 --- a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html +++ b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html @@ -42,9 +42,10 @@
  • color-teal
  • color-teal-darker
  • color-teal-dark
  • -
  • color-red
  • -
  • color-orange
  • -
  • color-green
  • + +
      +
    • color-salmon
    • +
    • color-salmon-light
    • color-grey-1
    • @@ -54,6 +55,12 @@
    • color-grey-4
    • color-grey-5
    +
      +
    • color-red
    • +
    • color-orange
    • +
    • color-green
    • +
    +
    @@ -149,29 +156,37 @@

    Buttons

    -
    button
    +
    button -
    button-secondary
    + button-secondary -
    yes
    + yes -
    no / serious
    + no / serious -
    bicolor with icon
    + bicolor with icon -
    button-small
    + button-small -
    bicolo button-small
    + bicolo button-small -
    mixed 1
    + mixed 1 -
    mixed 2
    + mixed 2 + +
    button on a div

    Buttons must have interaction possible (i.e be an input or button element) to get a suitable hover cursor

    + + + + + +
    diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/login.html b/wagtail/wagtailadmin/templates/wagtailadmin/login.html index 22682500f..52448ffe5 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/login.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/login.html @@ -28,17 +28,17 @@
    • -
      +
      {{ form.username.label_tag }} -
      +
      {{ form.username }}
    • -
      +
      {{ form.password.label_tag }} -
      +
      {{ form.password }}
      diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/notifications/approved.html b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/approved.html index 169f4ba7c..0af5ec841 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/notifications/approved.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/approved.html @@ -1,4 +1,4 @@ -{% load i18n %}{% blocktrans with title=revision.page.title|safe %}The page "{{ title }}" has been approved{% endblocktrans %} +{% extends 'wagtailadmin/notifications/base_notification.html' %}{% block notification %}{% load i18n %}{% blocktrans with title=revision.page.title|safe %}The page "{{ title }}" has been approved{% endblocktrans %} {% blocktrans with title=revision.page.title|safe %}The page "{{ title }}" has been approved.{% endblocktrans %} -{% trans "You can view the page here:" %} {{ revision.page.full_url }} \ No newline at end of file +{% trans "You can view the page here:" %} {{ revision.page.full_url }}{% endblock %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/notifications/base_notification.html b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/base_notification.html new file mode 100644 index 000000000..948fe186e --- /dev/null +++ b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/base_notification.html @@ -0,0 +1,3 @@ +{% load i18n %}{% block notification %}{% endblock %} + +{% trans "Edit your notification preferences here:" %} {{ settings.BASE_URL }}{% url 'wagtailadmin_account_notification_preferences' %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/notifications/rejected.html b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/rejected.html index 34a49fae7..f4be24976 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/notifications/rejected.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/rejected.html @@ -1,4 +1,4 @@ -{% load i18n %}{% blocktrans with title=revision.page.title|safe %}The page "{{ title }}" has been rejected{% endblocktrans %} +{% extends 'wagtailadmin/notifications/base_notification.html' %}{% block notification %}{% load i18n %}{% blocktrans with title=revision.page.title|safe %}The page "{{ title }}" has been rejected{% endblocktrans %} {% blocktrans with title=revision.page.title|safe %}The page "{{ title }}" has been rejected.{% endblocktrans %} -{% trans "You can edit the page here:"%} {{ settings.BASE_URL }}{% url 'wagtailadmin_pages_edit' revision.page.id %} \ No newline at end of file +{% trans "You can edit the page here:"%} {{ settings.BASE_URL }}{% url 'wagtailadmin_pages_edit' revision.page.id %}{% endblock %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/notifications/submitted.html b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/submitted.html index f3a9115e0..e048d73a5 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/notifications/submitted.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/notifications/submitted.html @@ -1,5 +1,5 @@ -{% load i18n %}{% blocktrans with page=revision.page|safe %}The page "{{ page }}" has been submitted for moderation{% endblocktrans %} +{% extends 'wagtailadmin/notifications/base_notification.html' %}{% block notification %}{% load i18n %}{% blocktrans with page=revision.page|safe %}The page "{{ page }}" has been submitted for moderation{% endblocktrans %} {% blocktrans with page=revision.page|safe %}The page "{{ page }}" has been submitted for moderation.{% endblocktrans %} {% trans "You can preview the page here:" %} {{ settings.BASE_URL }}{% url 'wagtailadmin_pages_preview_for_moderation' revision.id %} -{% trans "You can edit the page here:" %} {{ settings.BASE_URL }}{% url 'wagtailadmin_pages_edit' revision.page.id %} \ No newline at end of file +{% trans "You can edit the page here:" %} {{ settings.BASE_URL }}{% url 'wagtailadmin_pages_edit' revision.page.id %}{% endblock %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html index 3ddb8c1f3..17bcbd8bf 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html @@ -16,11 +16,9 @@ - - diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_create.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_create.html index 4f60050be..1809a6510 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_create.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_create.html @@ -1,4 +1,4 @@ - diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_edit.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_edit.html index a5cd5d24a..d24b9b0e9 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_edit.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_preview_button_on_edit.html @@ -1,4 +1,4 @@ - diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html index 1e1f7e9b3..901ee0ac1 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html @@ -70,7 +70,7 @@ {% blocktrans with last_mod=page.get_latest_revision.created_at %}Last modified: {{ last_mod }}{% endblocktrans %} {% if page.get_latest_revision.user %} {% blocktrans with modified_by=page.get_latest_revision.user.get_full_name|default:page.get_latest_revision.user.username %}by {{ modified_by }}{% endblocktrans %} - {% if request.user.email %} + {% if page.get_latest_revision.user.email %} {% endif %} {% endif %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/preview.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/preview.html index 46a4d2867..d5c4b7a51 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/preview.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/preview.html @@ -11,5 +11,6 @@
      + diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/shared/breadcrumb.html b/wagtail/wagtailadmin/templates/wagtailadmin/shared/breadcrumb.html index 583d92744..50bfb186e 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/shared/breadcrumb.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/shared/breadcrumb.html @@ -9,6 +9,6 @@ {% endif %} {% endfor %} {% if include_self %} -
    • >{{ page.title }}
    • +
    • {{ page.title }}
    • {% endif %}
    diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/shared/field.html b/wagtail/wagtailadmin/templates/wagtailadmin/shared/field.html new file mode 100644 index 000000000..9113e1b6b --- /dev/null +++ b/wagtail/wagtailadmin/templates/wagtailadmin/shared/field.html @@ -0,0 +1,25 @@ +{% load wagtailadmin_tags %} +
    + {{ field.label_tag }} +
    +
    + {% block form_field %} + {{ field }} + {% endblock %} + + {# This span only used on rare occasions by certain types of input #} + +
    + {% if field.help_text %} +

    {{ field.help_text }}

    + {% endif %} + + {% if field.errors %} +

    + {% for error in field.errors %} + {{ error|escape }} + {% endfor %} +

    + {% endif %} +
    +
    \ No newline at end of file diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/shared/field_as_li.html b/wagtail/wagtailadmin/templates/wagtailadmin/shared/field_as_li.html index a1f51174a..d10e9ba27 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/shared/field_as_li.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/shared/field_as_li.html @@ -1,25 +1,4 @@ {% load wagtailadmin_tags %} -
  • -
    - {{ field.label_tag }} -
    -
    - {% block form_field %} - {{ field }} - {% endblock %} - -
    - {% if field.help_text %} -

    {{ field.help_text }}

    - {% endif %} - - {% if field.errors %} -

    - {% for error in field.errors %} - {{ error|escape }} - {% endfor %} -

    - {% endif %} -
    -
    +
  • + {% include "wagtailadmin/shared/field.html" %}
  • \ No newline at end of file diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/shared/header.html b/wagtail/wagtailadmin/templates/wagtailadmin/shared/header.html index 59340d2d8..be391989d 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/shared/header.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/shared/header.html @@ -8,7 +8,7 @@
      {% for field in search_form %} - {% include "wagtailadmin/shared/field_as_li.html" with field=field input_classes="field-small iconfield icon-search" %} + {% include "wagtailadmin/shared/field_as_li.html" with field=field field_classes="field-small iconfield" input_classes="icon-search" %} {% endfor %}
    diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html b/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html index ac5038056..e24c1a588 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/skeleton.html @@ -1,10 +1,7 @@ {% load compress %} - - - - - + + diff --git a/wagtail/wagtailadmin/templatetags/gravatar.py b/wagtail/wagtailadmin/templatetags/gravatar.py index 5c69f4fd2..c831e6cb5 100644 --- a/wagtail/wagtailadmin/templatetags/gravatar.py +++ b/wagtail/wagtailadmin/templatetags/gravatar.py @@ -8,9 +8,11 @@ ### ### just make sure to update the "default" image path below -import urllib import hashlib +from six import b +from six.moves.urllib.parse import urlencode + from django import template register = template.Library() @@ -30,8 +32,8 @@ class GravatarUrlNode(template.Node): default = "blank" size = int(self.size) * 2 # requested at retina size by default and scaled down at point of use with css - gravatar_url = "//www.gravatar.com/avatar/" + hashlib.md5(email.lower()).hexdigest() + "?" - gravatar_url += urllib.urlencode({'s': str(size), 'd': default}) + gravatar_url = "//www.gravatar.com/avatar/" + hashlib.md5(b(email.lower())).hexdigest() + "?" + gravatar_url += urlencode({'s': str(size), 'd': default}) return gravatar_url diff --git a/wagtail/wagtailadmin/templatetags/wagtailadmin_tags.py b/wagtail/wagtailadmin/templatetags/wagtailadmin_tags.py index 9f03c7b63..f3c871507 100644 --- a/wagtail/wagtailadmin/templatetags/wagtailadmin_tags.py +++ b/wagtail/wagtailadmin/templatetags/wagtailadmin_tags.py @@ -1,10 +1,12 @@ +from __future__ import unicode_literals + from django import template from django.core import urlresolvers from django.utils.translation import ugettext_lazy as _ -from wagtail.wagtailadmin import hooks from wagtail.wagtailadmin.menu import MenuItem +from wagtail.wagtailcore import hooks from wagtail.wagtailcore.models import get_navigation_menu_items, UserPagePermissionsProxy from wagtail.wagtailcore.utils import camelcase_to_underscore @@ -95,4 +97,4 @@ def hook_output(hook_name): Note that the output is not escaped - it is the hook function's responsibility to escape unsafe content. """ snippets = [fn() for fn in hooks.get_hooks(hook_name)] - return u''.join(snippets) + return ''.join(snippets) diff --git a/wagtail/wagtailadmin/tests/test_account_management.py b/wagtail/wagtailadmin/tests/test_account_management.py index 566d68723..391baad9d 100644 --- a/wagtail/wagtailadmin/tests/test_account_management.py +++ b/wagtail/wagtailadmin/tests/test_account_management.py @@ -1,10 +1,14 @@ +from __future__ import unicode_literals + from django.test import TestCase -from wagtail.tests.utils import unittest, WagtailTestUtils from django.core.urlresolvers import reverse -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group, Permission from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core import mail +from wagtail.tests.utils import WagtailTestUtils +from wagtail.wagtailusers.models import UserProfile + class TestAuthentication(TestCase, WagtailTestUtils): """ @@ -177,6 +181,97 @@ class TestAccountSection(TestCase, WagtailTestUtils): # Check that the password was not changed self.assertTrue(User.objects.get(username='test').check_password('password')) + def test_notification_preferences_view(self): + """ + This tests that the notification preferences view responds with the + notification preferences page + """ + # Get notification preferences page + response = self.client.get(reverse('wagtailadmin_account_notification_preferences')) + + # Check that the user recieved a notification preferences page + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'wagtailadmin/account/notification_preferences.html') + + def test_notification_preferences_view_post(self): + """ + This posts to the notification preferences view and checks that the + user's profile is updated + """ + # Post new values to the notification preferences page + post_data = { + 'submitted_notifications': 'false', + 'approved_notifications': 'false', + 'rejected_notifications': 'true', + } + response = self.client.post(reverse('wagtailadmin_account_notification_preferences'), post_data) + + # Check that the user was redirected to the account page + self.assertRedirects(response, reverse('wagtailadmin_account')) + + profile = UserProfile.get_for_user(User.objects.get(username='test')) + + # Check that the notification preferences are as submitted + self.assertFalse(profile.submitted_notifications) + self.assertFalse(profile.approved_notifications) + self.assertTrue(profile.rejected_notifications) + + +class TestAccountManagementForNonModerator(TestCase, WagtailTestUtils): + """ + Tests of reduced-functionality for editors + """ + def setUp(self): + # Create a non-moderator user + self.submitter = User.objects.create_user('submitter', 'submitter@example.com', 'password') + self.submitter.groups.add(Group.objects.get(name='Editors')) + + self.client.login(username=self.submitter.username, password='password') + + def test_notification_preferences_form_is_reduced_for_non_moderators(self): + """ + This tests that a user without publish permissions is not shown the + notification preference for 'submitted' items + """ + response = self.client.get(reverse('wagtailadmin_account_notification_preferences')) + self.assertIn('approved_notifications', response.context['form'].fields.keys()) + self.assertIn('rejected_notifications', response.context['form'].fields.keys()) + self.assertNotIn('submitted_notifications', response.context['form'].fields.keys()) + + +class TestAccountManagementForAdminOnlyUser(TestCase, WagtailTestUtils): + """ + Tests for users with no edit/publish permissions at all + """ + def setUp(self): + # Create a non-moderator user + admin_only_group = Group.objects.create(name='Admin Only') + admin_only_group.permissions.add(Permission.objects.get(codename='access_admin')) + + self.admin_only_user = User.objects.create_user('admin_only_user', 'admin_only_user@example.com', 'password') + self.admin_only_user.groups.add(admin_only_group) + + self.client.login(username=self.admin_only_user.username, password='password') + + def test_notification_preferences_view_redirects_for_admin_only_users(self): + """ + Test that the user is not shown the notification preferences view but instead + redirected to the account page + """ + response = self.client.get(reverse('wagtailadmin_account_notification_preferences')) + self.assertRedirects(response, reverse('wagtailadmin_account')) + + def test_notification_preferences_link_not_shown_for_admin_only_users(self): + """ + Test that the user is not even shown the link to the notification + preferences view + """ + response = self.client.get(reverse('wagtailadmin_account')) + self.assertEqual(response.context['show_notification_preferences'], False) + self.assertNotContains(response, reverse('wagtailadmin_account_notification_preferences')) + # safety check that checking for absence/presence of urls works + self.assertContains(response, reverse('wagtailadmin_home')) + class TestPasswordReset(TestCase, WagtailTestUtils): """ diff --git a/wagtail/wagtailadmin/tests/test_pages_views.py b/wagtail/wagtailadmin/tests/test_pages_views.py index 8a901bfba..28a558bbb 100644 --- a/wagtail/wagtailadmin/tests/test_pages_views.py +++ b/wagtail/wagtailadmin/tests/test_pages_views.py @@ -1,11 +1,17 @@ +from datetime import timedelta + from django.test import TestCase -from wagtail.tests.models import SimplePage, EventPage, StandardIndex, StandardChild, BusinessIndex, BusinessChild, BusinessSubIndex -from wagtail.tests.utils import unittest, WagtailTestUtils -from wagtail.wagtailcore.models import Page, PageRevision from django.core.urlresolvers import reverse from django.contrib.auth.models import User, Permission from django.core import mail from django.core.paginator import Paginator +from django.utils import timezone + +from wagtail.tests.models import SimplePage, EventPage, StandardIndex, BusinessIndex, BusinessChild, BusinessSubIndex +from wagtail.tests.utils import WagtailTestUtils +from wagtail.wagtailcore.models import Page, PageRevision +from wagtail.wagtailcore.signals import page_published +from wagtail.wagtailusers.models import UserProfile class TestPageExplorer(TestCase, WagtailTestUtils): @@ -167,7 +173,71 @@ class TestPageCreation(TestCase, WagtailTestUtils): self.assertIsInstance(page, SimplePage) self.assertFalse(page.live) + def test_create_simplepage_scheduled(self): + go_live_at = timezone.now() + timedelta(days=1) + expire_at = timezone.now() + timedelta(days=2) + post_data = { + 'title': "New page!", + 'content': "Some content", + 'slug': 'hello-world', + 'go_live_at': str(go_live_at).split('.')[0], + 'expire_at': str(expire_at).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)), post_data) + + # Should be redirected to explorer page + self.assertEqual(response.status_code, 302) + + # Find the page and check the scheduled times + page = Page.objects.get(path__startswith=self.root_page.path, slug='hello-world').specific + self.assertEqual(page.go_live_at.date(), go_live_at.date()) + self.assertEqual(page.expire_at.date(), expire_at.date()) + self.assertEqual(page.expired, False) + self.assertTrue(page.status_string, "draft") + + # No revisions with approved_go_live_at + self.assertFalse(PageRevision.objects.filter(page=page).exclude(approved_go_live_at__isnull=True).exists()) + + def test_create_simplepage_scheduled_go_live_before_expiry(self): + post_data = { + 'title': "New page!", + 'content': "Some content", + 'slug': 'hello-world', + 'go_live_at': str(timezone.now() + timedelta(days=2)).split('.')[0], + 'expire_at': str(timezone.now() + timedelta(days=1)).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)), post_data) + + self.assertEqual(response.status_code, 200) + + # Check that a form error was raised + self.assertFormError(response, 'form', 'go_live_at', "Go live date/time must be before expiry date/time") + self.assertFormError(response, 'form', 'expire_at', "Go live date/time must be before expiry date/time") + + def test_create_simplepage_scheduled_expire_in_the_past(self): + post_data = { + 'title': "New page!", + 'content': "Some content", + 'slug': 'hello-world', + 'expire_at': str(timezone.now() + timedelta(days=-1)).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)), post_data) + + self.assertEqual(response.status_code, 200) + + # Check that a form error was raised + self.assertFormError(response, 'form', 'expire_at', "Expiry date/time must be in the future") + def test_create_simplepage_post_publish(self): + # Connect a mock signal handler to page_published signal + signal_fired = [False] + signal_page = [None] + def page_published_handler(sender, instance, **kwargs): + signal_fired[0] = True + signal_page[0] = instance + page_published.connect(page_published_handler) + + # Post post_data = { 'title': "New page!", 'content': "Some content", @@ -185,6 +255,39 @@ class TestPageCreation(TestCase, WagtailTestUtils): self.assertIsInstance(page, SimplePage) self.assertTrue(page.live) + # Check that the page_published signal was fired + self.assertTrue(signal_fired[0]) + self.assertEqual(signal_page[0], page) + self.assertEqual(signal_page[0], signal_page[0].specific) + + def test_create_simplepage_post_publish_scheduled(self): + go_live_at = timezone.now() + timedelta(days=1) + expire_at = timezone.now() + timedelta(days=2) + post_data = { + 'title': "New page!", + 'content': "Some content", + 'slug': 'hello-world', + 'action-publish': "Publish", + 'go_live_at': str(go_live_at).split('.')[0], + 'expire_at': str(expire_at).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)), post_data) + + # Should be redirected to explorer page + self.assertEqual(response.status_code, 302) + + # Find the page and check it + page = Page.objects.get(path__startswith=self.root_page.path, slug='hello-world').specific + self.assertEqual(page.go_live_at.date(), go_live_at.date()) + self.assertEqual(page.expire_at.date(), expire_at.date()) + self.assertEqual(page.expired, False) + + # A revision with approved_go_live_at should exist now + self.assertTrue(PageRevision.objects.filter(page=page).exclude(approved_go_live_at__isnull=True).exists()) + # But Page won't be live + self.assertFalse(page.live) + self.assertTrue(page.status_string, "scheduled") + def test_create_simplepage_post_submit(self): # Create a moderator user for testing email moderator = User.objects.create_superuser('moderator', 'moderator@email.com', 'password') @@ -243,7 +346,6 @@ class TestPageCreation(TestCase, WagtailTestUtils): response = self.client.get(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', 100000))) self.assertEqual(response.status_code, 404) - @unittest.expectedFailure # FIXME: Crashes! def test_create_nonpagetype(self): response = self.client.get(reverse('wagtailadmin_pages_create', args=('wagtailimages', 'image', self.root_page.id))) self.assertEqual(response.status_code, 404) @@ -325,7 +427,72 @@ class TestPageEdit(TestCase, WagtailTestUtils): child_page_new = SimplePage.objects.get(id=self.child_page.id) self.assertTrue(child_page_new.has_unpublished_changes) + def test_edit_post_scheduled(self): + go_live_at = timezone.now() + timedelta(days=1) + expire_at = timezone.now() + timedelta(days=2) + post_data = { + 'title': "I've been edited!", + 'content': "Some content", + 'slug': 'hello-world', + 'go_live_at': str(go_live_at).split('.')[0], + 'expire_at': str(expire_at).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data) + + # Should be redirected to explorer page + self.assertEqual(response.status_code, 302) + + child_page_new = SimplePage.objects.get(id=self.child_page.id) + + # The page will still be live + self.assertTrue(child_page_new.live) + + # A revision with approved_go_live_at should not exist + self.assertFalse(PageRevision.objects.filter(page=child_page_new).exclude(approved_go_live_at__isnull=True).exists()) + + # But a revision with go_live_at and expire_at in their content json *should* exist + self.assertTrue(PageRevision.objects.filter(page=child_page_new, content_json__contains=str(go_live_at.date())).exists()) + self.assertTrue(PageRevision.objects.filter(page=child_page_new, content_json__contains=str(expire_at.date())).exists()) + + def test_edit_scheduled_go_live_before_expiry(self): + post_data = { + 'title': "I've been edited!", + 'content': "Some content", + 'slug': 'hello-world', + 'go_live_at': str(timezone.now() + timedelta(days=2)).split('.')[0], + 'expire_at': str(timezone.now() + timedelta(days=1)).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data) + + self.assertEqual(response.status_code, 200) + + # Check that a form error was raised + self.assertFormError(response, 'form', 'go_live_at', "Go live date/time must be before expiry date/time") + self.assertFormError(response, 'form', 'expire_at', "Go live date/time must be before expiry date/time") + + def test_edit_scheduled_expire_in_the_past(self): + post_data = { + 'title': "I've been edited!", + 'content': "Some content", + 'slug': 'hello-world', + 'expire_at': str(timezone.now() + timedelta(days=-1)).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data) + + self.assertEqual(response.status_code, 200) + + # Check that a form error was raised + self.assertFormError(response, 'form', 'expire_at', "Expiry date/time must be in the future") + def test_page_edit_post_publish(self): + # Connect a mock signal handler to page_published signal + signal_fired = [False] + signal_page = [None] + def page_published_handler(sender, instance, **kwargs): + signal_fired[0] = True + signal_page[0] = instance + page_published.connect(page_published_handler) + # Tests publish from edit page post_data = { 'title': "I've been edited!", @@ -342,9 +509,85 @@ class TestPageEdit(TestCase, WagtailTestUtils): child_page_new = SimplePage.objects.get(id=self.child_page.id) self.assertEqual(child_page_new.title, post_data['title']) + # Check that the page_published signal was fired + self.assertTrue(signal_fired[0]) + self.assertEqual(signal_page[0], child_page_new) + self.assertEqual(signal_page[0], signal_page[0].specific) + # The page shouldn't have "has_unpublished_changes" flag set self.assertFalse(child_page_new.has_unpublished_changes) + def test_edit_post_publish_scheduled(self): + go_live_at = timezone.now() + timedelta(days=1) + expire_at = timezone.now() + timedelta(days=2) + post_data = { + 'title': "I've been edited!", + 'content': "Some content", + 'slug': 'hello-world', + 'action-publish': "Publish", + 'go_live_at': str(go_live_at).split('.')[0], + 'expire_at': str(expire_at).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data) + + # Should be redirected to explorer page + self.assertEqual(response.status_code, 302) + + child_page_new = SimplePage.objects.get(id=self.child_page.id) + + # The page should not be live anymore + self.assertFalse(child_page_new.live) + + # Instead a revision with approved_go_live_at should now exist + self.assertTrue(PageRevision.objects.filter(page=child_page_new).exclude(approved_go_live_at__isnull=True).exists()) + + def test_edit_post_publish_now_an_already_scheduled(self): + # First let's publish a page with a go_live_at in the future + go_live_at = timezone.now() + timedelta(days=1) + expire_at = timezone.now() + timedelta(days=2) + post_data = { + 'title': "I've been edited!", + 'content': "Some content", + 'slug': 'hello-world', + 'action-publish': "Publish", + 'go_live_at': str(go_live_at).split('.')[0], + 'expire_at': str(expire_at).split('.')[0], + } + response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data) + + # Should be redirected to explorer page + self.assertEqual(response.status_code, 302) + + child_page_new = SimplePage.objects.get(id=self.child_page.id) + + # The page should not be live anymore + self.assertFalse(child_page_new.live) + + # Instead a revision with approved_go_live_at should now exist + self.assertTrue(PageRevision.objects.filter(page=child_page_new).exclude(approved_go_live_at__isnull=True).exists()) + + # Now, let's edit it and publish it right now + go_live_at = timezone.now() + post_data = { + 'title': "I've been edited!", + 'content': "Some content", + 'slug': 'hello-world', + 'action-publish': "Publish", + 'go_live_at': "", + } + response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data) + + # Should be redirected to explorer page + self.assertEqual(response.status_code, 302) + + child_page_new = SimplePage.objects.get(id=self.child_page.id) + + # The page should be live now + self.assertTrue(child_page_new.live) + + # And a revision with approved_go_live_at should not exist + self.assertFalse(PageRevision.objects.filter(page=child_page_new).exclude(approved_go_live_at__isnull=True).exists()) + def test_page_edit_post_submit(self): # Create a moderator user for testing email moderator = User.objects.create_superuser('moderator', 'moderator@email.com', 'password') @@ -705,6 +948,14 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils): """ This posts to the approve moderation view and checks that the page was approved """ + # Connect a mock signal handler to page_published signal + signal_fired = [False] + signal_page = [None] + def page_published_handler(sender, instance, **kwargs): + signal_fired[0] = True + signal_page[0] = instance + page_published.connect(page_published_handler) + # Post response = self.client.post(reverse('wagtailadmin_pages_approve_moderation', args=(self.revision.id, )), { 'foo': "Must post something or the view won't see this as a POST request", @@ -716,10 +967,10 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils): # Page must be live self.assertTrue(Page.objects.get(id=self.page.id).live) - # Submitter must recieve an approved email - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].to, ['submitter@email.com']) - self.assertEqual(mail.outbox[0].subject, 'The page "Hello world!" has been approved') + # Check that the page_published signal was fired + self.assertTrue(signal_fired[0]) + self.assertEqual(signal_page[0], self.page) + self.assertEqual(signal_page[0], signal_page[0].specific) def test_approve_moderation_view_bad_revision_id(self): """ @@ -770,11 +1021,6 @@ class TestApproveRejectModeration(TestCase, WagtailTestUtils): # Revision must no longer be submitted for moderation self.assertFalse(PageRevision.objects.get(id=self.revision.id).submitted_for_moderation) - # Submitter must recieve a rejected email - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].to, ['submitter@email.com']) - self.assertEqual(mail.outbox[0].subject, 'The page "Hello world!" has been rejected') - def test_reject_moderation_view_bad_revision_id(self): """ This tests that the reject moderation view handles invalid revision ids correctly @@ -920,3 +1166,136 @@ class TestSubpageBusinessRules(TestCase, WagtailTestUtils): response = self.client.get(reverse('wagtailadmin_pages_add_subpage', args=(self.business_subindex.id, ))) # BusinessChild is the only valid subpage type of BusinessSubIndex, so redirect straight there self.assertRedirects(response, reverse('wagtailadmin_pages_create', args=('tests', 'businesschild', self.business_subindex.id))) + + +class TestNotificationPreferences(TestCase, WagtailTestUtils): + def setUp(self): + # Find root page + self.root_page = Page.objects.get(id=2) + + # Login + self.user = self.login() + + # Create two moderator users for testing 'submitted' email + self.moderator = User.objects.create_superuser('moderator', 'moderator@email.com', 'password') + self.moderator2 = User.objects.create_superuser('moderator2', 'moderator2@email.com', 'password') + + # Create a submitter for testing 'rejected' and 'approved' emails + self.submitter = User.objects.create_user('submitter', 'submitter@email.com', 'password') + + # User profiles for moderator2 and the submitter + self.moderator2_profile = UserProfile.get_for_user(self.moderator2) + self.submitter_profile = UserProfile.get_for_user(self.submitter) + + # Create a page and submit it for moderation + self.child_page = SimplePage( + title="Hello world!", + slug='hello-world', + live=False, + ) + self.root_page.add_child(instance=self.child_page) + + # POST data to edit the page + self.post_data = { + 'title': "I've been edited!", + 'content': "Some content", + 'slug': 'hello-world', + 'action-submit': "Submit", + } + + def submit(self): + return self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), self.post_data) + + def silent_submit(self): + """ + Sets up the child_page as needing moderation, without making a request + """ + self.child_page.save_revision(user=self.submitter, submitted_for_moderation=True) + self.revision = self.child_page.get_latest_revision() + + def approve(self): + return self.client.post(reverse('wagtailadmin_pages_approve_moderation', args=(self.revision.id, )), { + 'foo': "Must post something or the view won't see this as a POST request", + }) + + def reject(self): + return self.client.post(reverse('wagtailadmin_pages_reject_moderation', args=(self.revision.id, )), { + 'foo': "Must post something or the view won't see this as a POST request", + }) + + def test_vanilla_profile(self): + # Check that the vanilla profile has rejected notifications on + self.assertEqual(self.submitter_profile.rejected_notifications, True) + + # Check that the vanilla profile has approved notifications on + self.assertEqual(self.submitter_profile.approved_notifications, True) + + def test_submit_notifications_sent(self): + # Submit + self.submit() + + # Check that both the moderators got an email, and no others + self.assertEqual(len(mail.outbox), 1) + self.assertIn(self.moderator.email, mail.outbox[0].to) + self.assertIn(self.moderator2.email, mail.outbox[0].to) + self.assertEqual(len(mail.outbox[0].to), 2) + + def test_submit_notification_preferences_respected(self): + # moderator2 doesn't want emails + self.moderator2_profile.submitted_notifications = False + self.moderator2_profile.save() + + # Submit + self.submit() + + # Check that only one moderator got an email + self.assertEqual(len(mail.outbox), 1) + self.assertEqual([self.moderator.email], mail.outbox[0].to) + + def test_approved_notifications(self): + # Set up the page version + self.silent_submit() + # Approve + self.approve() + + # Submitter must recieve an approved email + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ['submitter@email.com']) + self.assertEqual(mail.outbox[0].subject, 'The page "Hello world!" has been approved') + + def test_approved_notifications_preferences_respected(self): + # Submitter doesn't want 'approved' emails + self.submitter_profile.approved_notifications = False + self.submitter_profile.save() + + # Set up the page version + self.silent_submit() + # Approve + self.approve() + + # No email to send + self.assertEqual(len(mail.outbox), 0) + + def test_rejected_notifications(self): + # Set up the page version + self.silent_submit() + # Reject + self.reject() + + # Submitter must recieve a rejected email + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ['submitter@email.com']) + self.assertEqual(mail.outbox[0].subject, 'The page "Hello world!" has been rejected') + + def test_rejected_notification_preferences_respected(self): + # Submitter doesn't want 'rejected' emails + self.submitter_profile.rejected_notifications = False + self.submitter_profile.save() + + # Set up the page version + self.silent_submit() + # Reject + self.reject() + + # No email to send + self.assertEqual(len(mail.outbox), 0) diff --git a/wagtail/wagtailadmin/tests/tests.py b/wagtail/wagtailadmin/tests/tests.py index f014c026d..4d5491f1d 100644 --- a/wagtail/wagtailadmin/tests/tests.py +++ b/wagtail/wagtailadmin/tests/tests.py @@ -1,6 +1,5 @@ from django.test import TestCase -from wagtail.tests.models import SimplePage, EventPage -from wagtail.tests.utils import unittest, WagtailTestUtils +from wagtail.tests.utils import WagtailTestUtils from wagtail.wagtailcore.models import Page from wagtail.wagtailadmin.tasks import send_email_task from django.core.urlresolvers import reverse diff --git a/wagtail/wagtailadmin/urls.py b/wagtail/wagtailadmin/urls.py index c0dfcac28..1eacbaadf 100644 --- a/wagtail/wagtailadmin/urls.py +++ b/wagtail/wagtailadmin/urls.py @@ -1,9 +1,8 @@ from django.conf.urls import url -from django.conf import settings -from wagtail.wagtailadmin.forms import LoginForm, PasswordResetForm +from wagtail.wagtailadmin.forms import PasswordResetForm from wagtail.wagtailadmin.views import account, chooser, home, pages, tags, userbar -from wagtail.wagtailadmin import hooks +from wagtail.wagtailcore import hooks urlpatterns = [ @@ -50,6 +49,7 @@ urlpatterns += [ url(r'^pages/(\d+)/edit/preview/$', pages.preview_on_edit, name='wagtailadmin_pages_preview_on_edit'), url(r'^pages/preview/$', pages.preview, name='wagtailadmin_pages_preview'), + url(r'^pages/preview_loading/$', pages.preview_loading, name='wagtailadmin_pages_preview_loading'), url(r'^pages/(\d+)/view_draft/$', pages.view_draft, name='wagtailadmin_pages_view_draft'), url(r'^pages/(\d+)/add_subpage/$', pages.add_subpage, name='wagtailadmin_pages_add_subpage'), @@ -79,6 +79,7 @@ urlpatterns += [ url(r'^login/$', account.login, name='wagtailadmin_login'), url(r'^account/$', account.account, name='wagtailadmin_account'), url(r'^account/change_password/$', account.change_password, name='wagtailadmin_account_change_password'), + url(r'^account/notification_preferences/$', account.notification_preferences, name='wagtailadmin_account_notification_preferences'), url(r'^logout/$', account.logout, name='wagtailadmin_logout'), url(r'^userbar/(\d+)/$', userbar.for_frontend, name='wagtailadmin_userbar_frontend'), diff --git a/wagtail/wagtailadmin/userbar.py b/wagtail/wagtailadmin/userbar.py index a641a7b1d..195e936b3 100644 --- a/wagtail/wagtailadmin/userbar.py +++ b/wagtail/wagtailadmin/userbar.py @@ -1,4 +1,3 @@ -from django.core.urlresolvers import reverse from django.template import RequestContext from django.template.loader import render_to_string diff --git a/wagtail/wagtailadmin/views/account.py b/wagtail/wagtailadmin/views/account.py index c5e461f55..65e8dbeec 100644 --- a/wagtail/wagtailadmin/views/account.py +++ b/wagtail/wagtailadmin/views/account.py @@ -9,12 +9,19 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.cache import never_cache from wagtail.wagtailadmin import forms +from wagtail.wagtailusers.forms import NotificationPreferencesForm +from wagtail.wagtailusers.models import UserProfile +from wagtail.wagtailcore.models import UserPagePermissionsProxy @permission_required('wagtailadmin.access_admin') def account(request): + user_perms = UserPagePermissionsProxy(request.user) + show_notification_preferences = user_perms.can_edit_pages() or user_perms.can_publish_pages() + return render(request, 'wagtailadmin/account/account.html', { 'show_change_password': getattr(settings, 'WAGTAIL_PASSWORD_MANAGEMENT_ENABLED', True) and request.user.has_usable_password(), + 'show_notification_preferences': show_notification_preferences }) @@ -42,6 +49,29 @@ def change_password(request): }) +@permission_required('wagtailadmin.access_admin') +def notification_preferences(request): + + if request.POST: + form = NotificationPreferencesForm(request.POST, instance=UserProfile.get_for_user(request.user)) + + if form.is_valid(): + form.save() + messages.success(request, _("Your preferences have been updated successfully!")) + return redirect('wagtailadmin_account') + else: + form = NotificationPreferencesForm(instance=UserProfile.get_for_user(request.user)) + + # quick-and-dirty catch-all in case the form has been rendered with no + # fields, as the user has no customisable permissions + if not form.fields: + return redirect('wagtailadmin_account') + + return render(request, 'wagtailadmin/account/notification_preferences.html', { + 'form': form, + }) + + @sensitive_post_parameters() @never_cache def login(request): diff --git a/wagtail/wagtailadmin/views/home.py b/wagtail/wagtailadmin/views/home.py index 24800eefc..e68ed0cbf 100644 --- a/wagtail/wagtailadmin/views/home.py +++ b/wagtail/wagtailadmin/views/home.py @@ -4,9 +4,9 @@ from django.conf import settings from django.template import RequestContext from django.template.loader import render_to_string -from wagtail.wagtailadmin import hooks from wagtail.wagtailadmin.forms import SearchForm +from wagtail.wagtailcore import hooks from wagtail.wagtailcore.models import Page, PageRevision, UserPagePermissionsProxy from wagtail.wagtaildocs.models import Document diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py index af3af33e1..c9cae9e16 100644 --- a/wagtail/wagtailadmin/views/pages.py +++ b/wagtail/wagtailadmin/views/pages.py @@ -5,14 +5,17 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.contrib.auth.decorators import permission_required from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.utils import timezone from django.utils.translation import ugettext as _ from django.views.decorators.vary import vary_on_headers from wagtail.wagtailadmin.edit_handlers import TabbedInterface, ObjectList from wagtail.wagtailadmin.forms import SearchForm, CopyForm -from wagtail.wagtailadmin import tasks, hooks +from wagtail.wagtailadmin import tasks, signals +from wagtail.wagtailcore import hooks from wagtail.wagtailcore.models import Page, PageRevision +from wagtail.wagtailcore.signals import page_published @permission_required('wagtailadmin.access_admin') @@ -115,12 +118,17 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ except ContentType.DoesNotExist: raise Http404 + # Get class + page_class = content_type.model_class() + + # Make sure the class is a descendant of Page + if not issubclass(page_class, Page): + raise Http404 + # page must be in the list of allowed subpage types for this parent ID if content_type not in parent_page.clean_subpage_types(): raise PermissionDenied - page_class = content_type.model_class() - page = page_class(owner=request.user) edit_handler_class = get_page_edit_handler(page_class) form_class = edit_handler_class.get_form_class(page_class) @@ -136,23 +144,66 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ return slug form.fields['slug'].clean = clean_slug + # Stick another validator into the form to check that the scheduled publishing settings are set correctly + def clean(): + cleaned_data = form_class.clean(form) + + # Go live must be before expire + go_live_at = cleaned_data.get('go_live_at') + expire_at = cleaned_data.get('expire_at') + + if go_live_at and expire_at: + if go_live_at > expire_at: + msg = _('Go live date/time must be before expiry date/time') + form._errors['go_live_at'] = form.error_class([msg]) + form._errors['expire_at'] = form.error_class([msg]) + del cleaned_data['go_live_at'] + del cleaned_data['expire_at'] + + # Expire must be in the future + expire_at = cleaned_data.get('expire_at') + + if expire_at and expire_at < timezone.now(): + form._errors['expire_at'] = form.error_class([_('Expiry date/time must be in the future')]) + del cleaned_data['expire_at'] + + return cleaned_data + form.clean = clean + if form.is_valid(): page = form.save(commit=False) # don't save yet, as we need treebeard to assign tree params is_publishing = bool(request.POST.get('action-publish')) and parent_page_perms.can_publish_subpage() is_submitting = bool(request.POST.get('action-submit')) + go_live_at = form.cleaned_data.get('go_live_at') + future_go_live = go_live_at and go_live_at > timezone.now() + approved_go_live_at = None if is_publishing: - page.live = True page.has_unpublished_changes = False + page.expired = False + if future_go_live: + page.live = False + # Set approved_go_live_at only if is publishing + # and the future_go_live is actually in future + approved_go_live_at = go_live_at + else: + page.live = True else: page.live = False page.has_unpublished_changes = True parent_page.add_child(instance=page) # assign tree parameters - will cause page to be saved - page.save_revision(user=request.user, submitted_for_moderation=is_submitting) + + # Pass approved_go_live_at to save_revision + page.save_revision( + user=request.user, + submitted_for_moderation=is_submitting, + approved_go_live_at=approved_go_live_at + ) if is_publishing: + page_published.send(sender=page_class, instance=page) messages.success(request, _("Page '{0}' published.").format(page.title)) elif is_submitting: messages.success(request, _("Page '{0}' submitted for moderation.").format(page.title)) @@ -167,9 +218,10 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ return redirect('wagtailadmin_explore', page.get_parent().id) else: - messages.error(request, _("The page could not be created due to errors.")) + messages.error(request, _("The page could not be created due to validation errors")) edit_handler = edit_handler_class(instance=page, form=form) else: + signals.init_new_page.send(sender=create, page=page, parent=parent_page) form = form_class(instance=page) edit_handler = edit_handler_class(instance=page, form=form) @@ -209,15 +261,54 @@ def edit(request, page_id): return slug form.fields['slug'].clean = clean_slug + # Stick another validator into the form to check that the scheduled publishing settings are set correctly + def clean(): + cleaned_data = form_class.clean(form) + + # Go live must be before expire + go_live_at = cleaned_data.get('go_live_at') + expire_at = cleaned_data.get('expire_at') + + if go_live_at and expire_at: + if go_live_at > expire_at: + msg = _('Go live date/time must be before expiry date/time') + form._errors['go_live_at'] = form.error_class([msg]) + form._errors['expire_at'] = form.error_class([msg]) + del cleaned_data['go_live_at'] + del cleaned_data['expire_at'] + + # Expire must be in the future + expire_at = cleaned_data.get('expire_at') + + if expire_at and expire_at < timezone.now(): + form._errors['expire_at'] = form.error_class([_('Expiry date/time must be in the future')]) + del cleaned_data['expire_at'] + + return cleaned_data + form.clean = clean + if form.is_valid(): is_publishing = bool(request.POST.get('action-publish')) and page_perms.can_publish() is_submitting = bool(request.POST.get('action-submit')) + go_live_at = form.cleaned_data.get('go_live_at') + future_go_live = go_live_at and go_live_at > timezone.now() + approved_go_live_at = None if is_publishing: - page.live = True page.has_unpublished_changes = False + page.expired = False + if future_go_live: + page.live = False + # Set approved_go_live_at only if publishing + approved_go_live_at = go_live_at + else: + page.live = True form.save() - page.revisions.update(submitted_for_moderation=False) + # Clear approved_go_live_at for older revisions + page.revisions.update( + submitted_for_moderation=False, + approved_go_live_at=None, + ) else: # not publishing the page if page.live: @@ -229,9 +320,14 @@ def edit(request, page_id): page.has_unpublished_changes = True form.save() - page.save_revision(user=request.user, submitted_for_moderation=is_submitting) + page.save_revision( + user=request.user, + submitted_for_moderation=is_submitting, + approved_go_live_at=approved_go_live_at + ) if is_publishing: + page_published.send(sender=page.__class__, instance=page) messages.success(request, _("Page '{0}' published.").format(page.title)) elif is_submitting: messages.success(request, _("Page '{0}' submitted for moderation.").format(page.title)) @@ -247,10 +343,11 @@ def edit(request, page_id): return redirect('wagtailadmin_explore', page.get_parent().id) else: messages.error(request, _("The page could not be saved due to validation errors")) + edit_handler = edit_handler_class(instance=page, form=form) errors_debug = ( repr(edit_handler.form.errors) - + repr([(name, formset.errors) for (name, formset) in edit_handler.form.formsets.iteritems() if formset.errors]) + + repr([(name, formset.errors) for (name, formset) in edit_handler.form.formsets.items() if formset.errors]) ) else: form = form_class(instance=page) @@ -422,6 +519,12 @@ def preview(request): """ return render(request, 'wagtailadmin/pages/preview.html') +def preview_loading(request): + """ + This page is blank, but must be real HTML so its DOM can be written to once the preview of the page has rendered + """ + return HttpResponse("") + @permission_required('wagtailadmin.access_admin') def unpublish(request, page_id): page = get_object_or_404(Page, id=page_id) @@ -432,6 +535,8 @@ def unpublish(request, page_id): parent_id = page.get_parent().id page.live = False page.save() + # Since page is unpublished clear the approved_go_live_at of all revisions + page.revisions.update(approved_go_live_at=None) messages.success(request, _("Page '{0}' unpublished.").format(page.title)) return redirect('wagtailadmin_explore', parent_id) @@ -587,7 +692,8 @@ def get_page_edit_handler(page_class): if page_class not in PAGE_EDIT_HANDLERS: PAGE_EDIT_HANDLERS[page_class] = TabbedInterface([ ObjectList(page_class.content_panels, heading='Content'), - ObjectList(page_class.promote_panels, heading='Promote') + ObjectList(page_class.promote_panels, heading='Promote'), + ObjectList(page_class.settings_panels, heading='Settings', classname="settings") ]) return PAGE_EDIT_HANDLERS[page_class] @@ -647,6 +753,7 @@ def approve_moderation(request, revision_id): if request.POST: revision.publish() + page_published.send(sender=revision.page.__class__, instance=revision.page.specific) messages.success(request, _("Page '{0}' published.").format(revision.page.title)) tasks.send_notification.delay(revision.id, 'approved', request.user.id) diff --git a/wagtail/wagtailadmin/views/userbar.py b/wagtail/wagtailadmin/views/userbar.py index 2b3749da0..c41b5aed1 100644 --- a/wagtail/wagtailadmin/views/userbar.py +++ b/wagtail/wagtailadmin/views/userbar.py @@ -2,7 +2,7 @@ from django.shortcuts import render from django.contrib.auth.decorators import permission_required from wagtail.wagtailadmin.userbar import EditPageItem, AddPageItem, ApproveModerationEditPageItem, RejectModerationEditPageItem -from wagtail.wagtailadmin import hooks +from wagtail.wagtailcore import hooks from wagtail.wagtailcore.models import Page, PageRevision diff --git a/wagtail/wagtailcore/compat.py b/wagtail/wagtailcore/compat.py index 71fbc39ac..54133e6e9 100644 --- a/wagtail/wagtailcore/compat.py +++ b/wagtail/wagtailcore/compat.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.exceptions import ImproperlyConfigured # A setting that can be used in foreign key declarations AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') diff --git a/wagtail/wagtailcore/hooks.py b/wagtail/wagtailcore/hooks.py new file mode 100644 index 000000000..d299c3346 --- /dev/null +++ b/wagtail/wagtailcore/hooks.py @@ -0,0 +1,38 @@ +from django.conf import settings +try: + from importlib import import_module +except ImportError: + # for Python 2.6, fall back on django.utils.importlib (deprecated as of Django 1.7) + from django.utils.importlib import import_module + +_hooks = {} + +# TODO: support 'register' as a decorator: +# @hooks.register('construct_main_menu') +# def construct_main_menu(menu_items): +# ... + + +def register(hook_name, fn): + if hook_name not in _hooks: + _hooks[hook_name] = [] + _hooks[hook_name].append(fn) + +_searched_for_hooks = False + + +def search_for_hooks(): + global _searched_for_hooks + if not _searched_for_hooks: + for app_module in settings.INSTALLED_APPS: + try: + import_module('%s.wagtail_hooks' % app_module) + except ImportError: + continue + + _searched_for_hooks = True + + +def get_hooks(hook_name): + search_for_hooks() + return _hooks.get(hook_name, []) diff --git a/wagtail/wagtailcore/management/commands/publish_scheduled_pages.py b/wagtail/wagtailcore/management/commands/publish_scheduled_pages.py new file mode 100644 index 000000000..9a69f5f2e --- /dev/null +++ b/wagtail/wagtailcore/management/commands/publish_scheduled_pages.py @@ -0,0 +1,110 @@ +from __future__ import print_function + +import json +from optparse import make_option + +from django.core.management.base import BaseCommand +from django.utils import dateparse, timezone +from wagtail.wagtailcore.models import Page, PageRevision + + +def revision_date_expired(r): + expiry_str = json.loads(r.content_json).get('expire_at') + if not expiry_str: + return False + expire_at = dateparse.parse_datetime(expiry_str) + if expire_at < timezone.now(): + return True + else: + return False + + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option( + '--dryrun', + action='store_true', + dest='dryrun', + default=False, + help='Dry run -- don\'t change anything.'), + ) + + def handle(self, *args, **options): + dryrun = False + if options['dryrun']: + print("Will do a dry run.") + dryrun = True + + # 1. get all expired pages with live = True + expired_pages = Page.objects.filter( + live=True, + expire_at__lt=timezone.now() + ) + if dryrun: + if expired_pages: + print("Expired pages to be deactivated:") + print("Expiry datetime\t\tSlug\t\tName") + print("---------------\t\t----\t\t----") + for ep in expired_pages: + print("{0}\t{1}\t{2}".format( + ep.expire_at.strftime("%Y-%m-%d %H:%M"), + ep.slug, + ep.title + )) + else: + print("No expired pages to be deactivated found.") + else: + expired_pages.update(expired=True, live=False) + + # 2. get all page revisions for moderation that have been expired + expired_revs = [ + r for r in PageRevision.objects.filter( + submitted_for_moderation=True + ) if revision_date_expired(r) + ] + if dryrun: + print("---------------------------------") + if expired_revs: + print("Expired revisions to be dropped from moderation queue:") + print("Expiry datetime\t\tSlug\t\tName") + print("---------------\t\t----\t\t----") + for er in expired_revs: + rev_data = json.loads(er.content_json) + print("{0}\t{1}\t{2}".format( + dateparse.parse_datetime( + rev_data.get('expire_at') + ).strftime("%Y-%m-%d %H:%M"), + rev_data.get('slug'), + rev_data.get('title') + )) + else: + print("No expired revision to be dropped from moderation.") + else: + for er in expired_revs: + er.submitted_for_moderation = False + er.save() + + # 3. get all revisions that need to be published + revs_for_publishing = PageRevision.objects.filter( + approved_go_live_at__lt=timezone.now() + ) + if dryrun: + print("---------------------------------") + if revs_for_publishing: + print("Revisions to be published:") + print("Go live datetime\t\tSlug\t\tName") + print("---------------\t\t\t----\t\t----") + for rp in revs_for_publishing: + rev_data = json.loads(rp.content_json) + print("{0}\t\t{1}\t{2}".format( + rp.approved_go_live_at.strftime("%Y-%m-%d %H:%M"), + rev_data.get('slug'), + rev_data.get('title') + )) + else: + print("No pages to go live.") + else: + for rp in revs_for_publishing: + # just run publish for the revision -- since the approved go + # live datetime is before now it will make the page live + rp.publish() diff --git a/wagtail/wagtailcore/migrations/0001_initial.py b/wagtail/wagtailcore/migrations/0001_initial.py index 954bc38c5..31af299fc 100644 --- a/wagtail/wagtailcore/migrations/0001_initial.py +++ b/wagtail/wagtailcore/migrations/0001_initial.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import SchemaMigration @@ -11,18 +13,18 @@ class Migration(SchemaMigration): def forwards(self, orm): # Adding model 'Site' - db.create_table(u'wagtailcore_site', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + db.create_table('wagtailcore_site', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('hostname', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255, db_index=True)), ('port', self.gf('django.db.models.fields.IntegerField')(default=80)), ('root_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='sites_rooted_here', to=orm['wagtailcore.Page'])), ('is_default_site', self.gf('django.db.models.fields.BooleanField')(default=False)), )) - db.send_create_signal(u'wagtailcore', ['Site']) + db.send_create_signal('wagtailcore', ['Site']) # Adding model 'Page' - db.create_table(u'wagtailcore_page', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + db.create_table('wagtailcore_page', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('path', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)), ('depth', self.gf('django.db.models.fields.PositiveIntegerField')()), ('numchild', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), @@ -37,55 +39,55 @@ class Migration(SchemaMigration): ('show_in_menus', self.gf('django.db.models.fields.BooleanField')(default=False)), ('search_description', self.gf('django.db.models.fields.TextField')(blank=True)), )) - db.send_create_signal(u'wagtailcore', ['Page']) + db.send_create_signal('wagtailcore', ['Page']) # Adding model 'PageRevision' - db.create_table(u'wagtailcore_pagerevision', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + db.create_table('wagtailcore_pagerevision', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='revisions', to=orm['wagtailcore.Page'])), ('submitted_for_moderation', self.gf('django.db.models.fields.BooleanField')(default=False)), ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm[AUTH_USER_MODEL], null=True, blank=True)), ('content_json', self.gf('django.db.models.fields.TextField')()), )) - db.send_create_signal(u'wagtailcore', ['PageRevision']) + db.send_create_signal('wagtailcore', ['PageRevision']) # Adding model 'GroupPagePermission' - db.create_table(u'wagtailcore_grouppagepermission', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + db.create_table('wagtailcore_grouppagepermission', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('group', self.gf('django.db.models.fields.related.ForeignKey')(related_name='page_permissions', to=orm['auth.Group'])), ('page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='group_permissions', to=orm['wagtailcore.Page'])), ('permission_type', self.gf('django.db.models.fields.CharField')(max_length=20)), )) - db.send_create_signal(u'wagtailcore', ['GroupPagePermission']) + db.send_create_signal('wagtailcore', ['GroupPagePermission']) def backwards(self, orm): # Deleting model 'Site' - db.delete_table(u'wagtailcore_site') + db.delete_table('wagtailcore_site') # Deleting model 'Page' - db.delete_table(u'wagtailcore_page') + db.delete_table('wagtailcore_page') # Deleting model 'PageRevision' - db.delete_table(u'wagtailcore_pagerevision') + db.delete_table('wagtailcore_pagerevision') # Deleting model 'GroupPagePermission' - db.delete_table(u'wagtailcore_grouppagepermission') + db.delete_table('wagtailcore_grouppagepermission') models = { - u'auth.group': { + 'auth.group': { 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) }, AUTH_USER_MODEL: { @@ -93,40 +95,40 @@ class Migration(SchemaMigration): 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) }, - u'contenttypes.contenttype': { + 'contenttypes.contenttype': { 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - u'wagtailcore.grouppagepermission': { + 'wagtailcore.grouppagepermission': { 'Meta': {'object_name': 'GroupPagePermission'}, - 'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': u"orm['wagtailcore.Page']"}), + 'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': "orm['wagtailcore.Page']"}), 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}) }, - u'wagtailcore.page': { + 'wagtailcore.page': { 'Meta': {'object_name': 'Page'}, - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"orm['contenttypes.ContentType']"}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['contenttypes.ContentType']"}), 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': u"orm['%s']" % AUTH_USER_MODEL}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': "orm['%s']" % AUTH_USER_MODEL}), 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), @@ -135,22 +137,22 @@ class Migration(SchemaMigration): 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) }, - u'wagtailcore.pagerevision': { + 'wagtailcore.pagerevision': { 'Meta': {'object_name': 'PageRevision'}, 'content_json': ('django.db.models.fields.TextField', [], {}), 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': u"orm['wagtailcore.Page']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': "orm['wagtailcore.Page']"}), 'submitted_for_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) }, - u'wagtailcore.site': { + 'wagtailcore.site': { 'Meta': {'object_name': 'Site'}, 'hostname': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_default_site': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'port': ('django.db.models.fields.IntegerField', [], {'default': '80'}), - 'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': u"orm['wagtailcore.Page']"}) + 'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': "orm['wagtailcore.Page']"}) } } diff --git a/wagtail/wagtailcore/migrations/0002_initial_data.py b/wagtail/wagtailcore/migrations/0002_initial_data.py index 668f554bd..bb0b5f3b9 100644 --- a/wagtail/wagtailcore/migrations/0002_initial_data.py +++ b/wagtail/wagtailcore/migrations/0002_initial_data.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import DataMigration @@ -61,17 +63,17 @@ class Migration(DataMigration): orm['wagtailcore.page'].objects.all().delete() models = { - u'auth.group': { + 'auth.group': { 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) }, AUTH_USER_MODEL: { @@ -79,40 +81,40 @@ class Migration(DataMigration): 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) }, - u'contenttypes.contenttype': { + 'contenttypes.contenttype': { 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - u'wagtailcore.grouppagepermission': { + 'wagtailcore.grouppagepermission': { 'Meta': {'object_name': 'GroupPagePermission'}, - 'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': u"orm['wagtailcore.Page']"}), + 'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': "orm['wagtailcore.Page']"}), 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}) }, - u'wagtailcore.page': { + 'wagtailcore.page': { 'Meta': {'object_name': 'Page'}, - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"orm['contenttypes.ContentType']"}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['contenttypes.ContentType']"}), 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': u"orm['%s']" % AUTH_USER_MODEL}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': "orm['%s']" % AUTH_USER_MODEL}), 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), @@ -121,22 +123,22 @@ class Migration(DataMigration): 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) }, - u'wagtailcore.pagerevision': { + 'wagtailcore.pagerevision': { 'Meta': {'object_name': 'PageRevision'}, 'content_json': ('django.db.models.fields.TextField', [], {}), 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': u"orm['wagtailcore.Page']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': "orm['wagtailcore.Page']"}), 'submitted_for_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) }, - u'wagtailcore.site': { + 'wagtailcore.site': { 'Meta': {'object_name': 'Site'}, 'hostname': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_default_site': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'port': ('django.db.models.fields.IntegerField', [], {'default': '80'}), - 'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': u"orm['wagtailcore.Page']"}) + 'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': "orm['wagtailcore.Page']"}) } } diff --git a/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py b/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py new file mode 100644 index 000000000..a7060ddc7 --- /dev/null +++ b/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Removing unique constraint on 'Site', fields ['hostname'] + db.delete_unique('wagtailcore_site', ['hostname']) + + # Adding unique constraint on 'Site', fields ['hostname', 'port'] + db.create_unique('wagtailcore_site', ['hostname', 'port']) + + + def backwards(self, orm): + # Removing unique constraint on 'Site', fields ['hostname', 'port'] + db.delete_unique('wagtailcore_site', ['hostname', 'port']) + + # Adding unique constraint on 'Site', fields ['hostname'] + db.create_unique('wagtailcore_site', ['hostname']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'wagtailcore.grouppagepermission': { + 'Meta': {'object_name': 'GroupPagePermission'}, + 'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': "orm['wagtailcore.Page']"}), + 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}) + }, + 'wagtailcore.page': { + 'Meta': {'object_name': 'Page'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['contenttypes.ContentType']"}), + 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': "orm['auth.User']"}), + 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'show_in_menus': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) + }, + 'wagtailcore.pagerevision': { + 'Meta': {'object_name': 'PageRevision'}, + 'content_json': ('django.db.models.fields.TextField', [], {}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': "orm['wagtailcore.Page']"}), + 'submitted_for_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}) + }, + 'wagtailcore.site': { + 'Meta': {'unique_together': "(('hostname', 'port'),)", 'object_name': 'Site'}, + 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_default_site': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'port': ('django.db.models.fields.IntegerField', [], {'default': '80'}), + 'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': "orm['wagtailcore.Page']"}) + } + } + + complete_apps = ['wagtailcore'] \ No newline at end of file diff --git a/wagtail/wagtailcore/migrations/0004_fields_for_scheduled_publishing.py b/wagtail/wagtailcore/migrations/0004_fields_for_scheduled_publishing.py new file mode 100644 index 000000000..9d53df617 --- /dev/null +++ b/wagtail/wagtailcore/migrations/0004_fields_for_scheduled_publishing.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'PageRevision.approved_go_live_at' + db.add_column('wagtailcore_pagerevision', 'approved_go_live_at', + self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), + keep_default=False) + + # Adding field 'Page.go_live_at' + db.add_column('wagtailcore_page', 'go_live_at', + self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), + keep_default=False) + + # Adding field 'Page.expire_at' + db.add_column('wagtailcore_page', 'expire_at', + self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), + keep_default=False) + + # Adding field 'Page.expired' + db.add_column('wagtailcore_page', 'expired', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'PageRevision.approved_go_live_at' + db.delete_column('wagtailcore_pagerevision', 'approved_go_live_at') + + # Deleting field 'Page.go_live_at' + db.delete_column('wagtailcore_page', 'go_live_at') + + # Deleting field 'Page.expire_at' + db.delete_column('wagtailcore_page', 'expire_at') + + # Deleting field 'Page.expired' + db.delete_column('wagtailcore_page', 'expired') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'wagtailcore.grouppagepermission': { + 'Meta': {'object_name': 'GroupPagePermission'}, + 'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': "orm['wagtailcore.Page']"}), + 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}) + }, + 'wagtailcore.page': { + 'Meta': {'object_name': 'Page'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['contenttypes.ContentType']"}), + 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'expire_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'expired': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'go_live_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': "orm['auth.User']"}), + 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'show_in_menus': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) + }, + 'wagtailcore.pagerevision': { + 'Meta': {'object_name': 'PageRevision'}, + 'approved_go_live_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'content_json': ('django.db.models.fields.TextField', [], {}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': "orm['wagtailcore.Page']"}), + 'submitted_for_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}) + }, + 'wagtailcore.site': { + 'Meta': {'unique_together': "(('hostname', 'port'),)", 'object_name': 'Site'}, + 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_default_site': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'port': ('django.db.models.fields.IntegerField', [], {'default': '80'}), + 'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': "orm['wagtailcore.Page']"}) + } + } + + complete_apps = ['wagtailcore'] \ No newline at end of file diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index 22d182284..b980d7f54 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -1,7 +1,10 @@ -from StringIO import StringIO -from urlparse import urlparse import warnings +import six +from six import string_types +from six import StringIO +from six.moves.urllib.parse import urlparse + from modelcluster.models import ClusterableModel from django.db import models, connection, transaction @@ -14,7 +17,11 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import Group from django.conf import settings from django.template.response import TemplateResponse +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ValidationError +from django.utils.functional import cached_property +from django.utils.encoding import python_2_unicode_compatible from treebeard.mp_tree import MP_Node @@ -25,29 +32,48 @@ from wagtail.wagtailsearch import Indexed, get_search_backend class SiteManager(models.Manager): - def get_by_natural_key(self, hostname): - return self.get(hostname=hostname) + def get_by_natural_key(self, hostname, port): + return self.get(hostname=hostname, port=port) +@python_2_unicode_compatible class Site(models.Model): - hostname = models.CharField(max_length=255, unique=True, db_index=True) + hostname = models.CharField(max_length=255, db_index=True) port = models.IntegerField(default=80, help_text=_("Set this to something other than 80 if you need a specific port number to appear in URLs (e.g. development on port 8000). Does not affect request handling (so port forwarding still works).")) root_page = models.ForeignKey('Page', related_name='sites_rooted_here') is_default_site = models.BooleanField(default=False, help_text=_("If true, this site will handle requests for all other hostnames that do not have a site entry of their own")) - def natural_key(self): - return (self.hostname,) + class Meta: + unique_together = ('hostname', 'port') - def __unicode__(self): + def natural_key(self): + return (self.hostname, self.port) + + def __str__(self): return self.hostname + ("" if self.port == 80 else (":%d" % self.port)) + (" [default]" if self.is_default_site else "") @staticmethod def find_for_request(request): - """Find the site object responsible for responding to this HTTP request object""" + """ + Find the site object responsible for responding to this HTTP + request object. Try: + - unique hostname first + - then hostname and port + - if there is no matching hostname at all, or no matching + hostname:port combination, fall back to the unique default site, + or raise an exception + NB this means that high-numbered ports on an extant hostname may + still be routed to a different hostname which is set as the default + """ try: - hostname = request.META['HTTP_HOST'].split(':')[0] - # find a Site matching this specific hostname - return Site.objects.get(hostname=hostname) + hostname = request.META['HTTP_HOST'].split(':')[0] # KeyError here goes to the final except clause + try: + # find a Site matching this specific hostname + return Site.objects.get(hostname=hostname) # Site.DoesNotExist here goes to the final except clause + except Site.MultipleObjectsReturned: + # as there were more than one, try matching by port too + port = request.META['SERVER_PORT'] # KeyError here goes to the final except clause + return Site.objects.get(hostname=hostname, port=int(port)) # Site.DoesNotExist here goes to the final except clause except (Site.DoesNotExist, KeyError): # If no matching site exists, or request does not specify an HTTP_HOST (which # will often be the case for the Django test client), look for a catch-all Site. @@ -63,6 +89,24 @@ class Site(models.Model): else: return 'http://%s:%d' % (self.hostname, self.port) + def clean_fields(self, exclude=None): + super(Site, self).clean_fields(exclude) + # Only one site can have the is_default_site flag set + try: + default = Site.objects.get(is_default_site=True) + except Site.DoesNotExist: + pass + except Site.MultipleObjectsReturned: + raise + else: + if self.is_default_site and self.pk != default.pk: + raise ValidationError( + {'is_default_site': [ + _("%(hostname)s is already configured as the default site. You must unset that before you can save this site as default.") + % { 'hostname': default.hostname } + ]} + ) + # clear the wagtail_site_root_paths cache whenever Site records are updated def save(self, *args, **kwargs): result = super(Site, self).save(*args, **kwargs) @@ -135,6 +179,12 @@ class PageManager(models.Manager): def not_live(self): return self.get_queryset().not_live() + def in_menu(self): + return self.get_queryset().in_menu() + + def not_in_menu(self): + return self.get_queryset().not_in_menu() + def page(self, other): return self.get_queryset().page(other) @@ -209,9 +259,8 @@ class PageBase(models.base.ModelBase): PAGE_MODEL_CLASSES.append(cls) -class Page(MP_Node, ClusterableModel, Indexed): - __metaclass__ = PageBase - +@python_2_unicode_compatible +class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)): title = models.CharField(max_length=255, help_text=_("The page title as you'd like it to be seen by the public")) slug = models.SlugField(help_text=_("The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/")) # TODO: enforce uniqueness on slug field per parent (will have to be done at the Django @@ -226,6 +275,10 @@ class Page(MP_Node, ClusterableModel, Indexed): show_in_menus = models.BooleanField(default=False, help_text=_("Whether a link to this page will appear in automatically generated menus")) search_description = models.TextField(blank=True) + go_live_at = models.DateTimeField(verbose_name=_("Go live date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True) + expire_at = models.DateTimeField(verbose_name=_("Expiry date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True) + expired = models.BooleanField(default=False, editable=False) + indexed_fields = { 'title': { 'type': 'string', @@ -250,7 +303,7 @@ class Page(MP_Node, ClusterableModel, Indexed): # created as self.content_type = ContentType.objects.get_for_model(self) - def __unicode__(self): + def __str__(self): return self.title is_abstract = True # don't offer Page in the list of page types a superuser can create @@ -320,10 +373,10 @@ class Page(MP_Node, ClusterableModel, Indexed): SET url_path = %s || substring(url_path from %s) WHERE path LIKE %s AND id <> %s """ - cursor.execute(update_statement, + cursor.execute(update_statement, [new_url_path, len(old_url_path) + 1, self.path + '%', self.id]) - @property + @cached_property def specific(self): """ Return this page in its most specific subclassed form. @@ -337,7 +390,7 @@ class Page(MP_Node, ClusterableModel, Indexed): else: return content_type.get_object_for_this_type(id=self.id) - @property + @cached_property def specific_class(self): """ return the class that this page would be if instantiated in its @@ -366,24 +419,24 @@ class Page(MP_Node, ClusterableModel, Indexed): else: raise Http404 - def save_revision(self, user=None, submitted_for_moderation=False): - self.revisions.create(content_json=self.to_json(), user=user, submitted_for_moderation=submitted_for_moderation) + def save_revision(self, user=None, submitted_for_moderation=False, approved_go_live_at=None): + return self.revisions.create( + content_json=self.to_json(), + user=user, + submitted_for_moderation=submitted_for_moderation, + approved_go_live_at=approved_go_live_at, + ) def get_latest_revision(self): - try: - revision = self.revisions.order_by('-created_at')[0] - except IndexError: - return False - - return revision + return self.revisions.order_by('-created_at').first() def get_latest_revision_as_page(self): - try: - revision = self.revisions.order_by('-created_at')[0] - except IndexError: - return self.specific + latest_revision = self.get_latest_revision() - return revision.as_page_object() + if latest_revision: + return latest_revision.as_page_object() + else: + return self.specific def get_context(self, request, *args, **kwargs): return { @@ -399,8 +452,8 @@ class Page(MP_Node, ClusterableModel, Indexed): def serve(self, request, *args, **kwargs): return TemplateResponse( - request, - self.get_template(request, *args, **kwargs), + request, + self.get_template(request, *args, **kwargs), self.get_context(request, *args, **kwargs) ) @@ -413,6 +466,10 @@ class Page(MP_Node, ClusterableModel, Indexed): return (not self.is_leaf()) or self.depth == 2 def get_other_siblings(self): + warnings.warn( + "The 'Page.get_other_siblings()' method has been replaced. " + "Use 'Page.get_siblings(inclusive=False)' instead.", DeprecationWarning) + # get sibling pages excluding self return self.get_siblings().exclude(id=self.id) @@ -482,7 +539,7 @@ class Page(MP_Node, ClusterableModel, Indexed): else: res = [] for page_type in cls.subpage_types: - if isinstance(page_type, basestring): + if isinstance(page_type, string_types): try: app_label, model_name = page_type.split(".") except ValueError: @@ -527,13 +584,22 @@ class Page(MP_Node, ClusterableModel, Indexed): @property def status_string(self): if not self.live: - return "draft" + if self.expired: + return "expired" + elif self.approved_schedule: + return "scheduled" + else: + return "draft" else: if self.has_unpublished_changes: return "live + draft" else: return "live" + @property + def approved_schedule(self): + return self.revisions.exclude(approved_go_live_at__isnull=True).exists() + def has_unpublished_subtree(self): """ An awkwardly-defined flag used in determining whether unprivileged editors have @@ -660,12 +726,28 @@ class Page(MP_Node, ClusterableModel, Indexed): """ return self.serve(self.dummy_request()) + def get_cached_paths(self): + """ + This returns a list of paths to invalidate in a frontend cache + """ + return ['/'] + + def get_sitemap_urls(self): + latest_revision = self.get_latest_revision() + + return [ + { + 'location': self.url, + 'lastmod': latest_revision.created_at if latest_revision else None + } + ] + def get_static_site_paths(self): """ This is a generator of URL paths to feed into a static site generator Override this if you would like to create static versions of subpages """ - # Yield paths for this page + # Yield path for this page yield '/' # Yield paths for child pages @@ -682,6 +764,12 @@ class Page(MP_Node, ClusterableModel, Indexed): def get_siblings(self, inclusive=True): return Page.objects.sibling_of(self, inclusive) + def get_next_siblings(self, inclusive=False): + return self.get_siblings(inclusive).filter(path__gte=self.path).order_by('path') + + def get_prev_siblings(self, inclusive=False): + return self.get_siblings(inclusive).filter(path__lte=self.path).order_by('-path') + def get_navigation_menu_items(): # Get all pages that appear in the navigation menu: ones which have children, @@ -738,12 +826,14 @@ class SubmittedRevisionsManager(models.Manager): return super(SubmittedRevisionsManager, self).get_queryset().filter(submitted_for_moderation=True) +@python_2_unicode_compatible class PageRevision(models.Model): page = models.ForeignKey('Page', related_name='revisions') submitted_for_moderation = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) content_json = models.TextField() + approved_go_live_at = models.DateTimeField(null=True, blank=True) objects = models.Manager() submitted_revisions = SubmittedRevisionsManager() @@ -777,11 +867,27 @@ class PageRevision(models.Model): def publish(self): page = self.as_page_object() - page.live = True + if page.go_live_at and page.go_live_at > timezone.now(): + # if we have a go_live in the future don't make the page live + page.live = False + # Instead set the approved_go_live_at of this revision + self.approved_go_live_at = page.go_live_at + self.save() + # And clear the the approved_go_live_at of any other revisions + page.revisions.exclude(id=self.id).update(approved_go_live_at=None) + else: + page.live = True + # If page goes live clear the approved_go_live_at of all revisions + page.revisions.update(approved_go_live_at=None) + page.expired = False # When a page is published it can't be expired page.save() self.submitted_for_moderation = False page.revisions.update(submitted_for_moderation=False) + def __str__(self): + return '"' + unicode(self.page) + '" at ' + unicode(self.created_at) + + PAGE_PERMISSION_TYPE_CHOICES = [ ('add', 'Add'), ('edit', 'Edit'), @@ -840,18 +946,39 @@ class UserPagePermissionsProxy(object): if self.user.is_superuser: return Page.objects.all() + editable_pages = Page.objects.none() + + for perm in self.permissions.filter(permission_type='add'): + # user has edit permission on any subpage of perm.page + # (including perm.page itself) that is owned by them + editable_pages |= Page.objects.descendant_of(perm.page, inclusive=True).filter(owner=self.user) + + for perm in self.permissions.filter(permission_type='edit'): + # user has edit permission on any subpage of perm.page + # (including perm.page itself) regardless of owner + editable_pages |= Page.objects.descendant_of(perm.page, inclusive=True) + + return editable_pages + + + def can_edit_pages(self): + """Return True if the user has permission to edit any pages""" + return True if self.editable_pages().count() else False + + def publishable_pages(self): + """Return a queryset of the pages that this user has permission to publish""" + # Deal with the trivial cases first... + if not self.user.is_active: + return Page.objects.none() + if self.user.is_superuser: + return Page.objects.all() + # Translate each of the user's permission rules into a Q-expression q_expressions = [] for perm in self.permissions: - if perm.permission_type == 'add': - # user has edit permission on any subpage of perm.page - # (including perm.page itself) that is owned by them - q_expressions.append( - Q(path__startswith=perm.page.path, owner=self.user) - ) - elif perm.permission_type == 'edit': - # user has edit permission on any subpage of perm.page - # (including perm.page itself) regardless of owner + if perm.permission_type == 'publish': + # user has publish permission on any subpage of perm.page + # (including perm.page itself) q_expressions.append( Q(path__startswith=perm.page.path) ) @@ -864,6 +991,11 @@ class UserPagePermissionsProxy(object): else: return Page.objects.none() + def can_publish_pages(self): + """Return True if the user has permission to publish any pages""" + return True if self.publishable_pages().count() else False + + class PagePermissionTester(object): def __init__(self, user_perms, page): self.user = user_perms.user diff --git a/wagtail/wagtailcore/query.py b/wagtail/wagtailcore/query.py index 9404e25b4..57e8ffff3 100644 --- a/wagtail/wagtailcore/query.py +++ b/wagtail/wagtailcore/query.py @@ -16,6 +16,15 @@ class PageQuerySet(MP_NodeQuerySet): def not_live(self): return self.exclude(self.live_q()) + def in_menu_q(self): + return Q(show_in_menus=True) + + def in_menu(self): + return self.filter(self.in_menu_q()) + + def not_in_menu(self): + return self.exclude(self.in_menu_q()) + def page_q(self, other): return Q(id=other.id) diff --git a/wagtail/wagtailcore/rich_text.py b/wagtail/wagtailcore/rich_text.py index 8004cb30d..bd218d01d 100644 --- a/wagtail/wagtailcore/rich_text.py +++ b/wagtail/wagtailcore/rich_text.py @@ -13,7 +13,7 @@ from wagtail.wagtaildocs.models import Document from wagtail.wagtailimages.models import get_image_model from wagtail.wagtailimages.formats import get_image_format -from wagtail.wagtailadmin import hooks +from wagtail.wagtailcore import hooks # Define a set of 'embed handlers' and 'link handlers'. These handle the translation @@ -166,8 +166,8 @@ class DbWhitelister(Whitelister): def clean(cls, html): if not cls.has_loaded_custom_whitelist_rules: for fn in hooks.get_hooks('construct_whitelister_element_rules'): - cls.element_rules = dict( - cls.element_rules.items() + fn().items()) + cls.element_rules = cls.element_rules.copy() + cls.element_rules.update(fn()) cls.has_loaded_custom_whitelist_rules = True return super(DbWhitelister, cls).clean(html) diff --git a/wagtail/wagtailcore/signals.py b/wagtail/wagtailcore/signals.py new file mode 100644 index 000000000..2508759c6 --- /dev/null +++ b/wagtail/wagtailcore/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + + +page_published = Signal(providing_args=['instance']) diff --git a/wagtail/wagtailcore/templatetags/pageurl.py b/wagtail/wagtailcore/templatetags/pageurl.py index d3488d9b4..63d2eb45e 100644 --- a/wagtail/wagtailcore/templatetags/pageurl.py +++ b/wagtail/wagtailcore/templatetags/pageurl.py @@ -1,24 +1,8 @@ -from django import template +import warnings -from wagtail.wagtailcore.models import Page - -register = template.Library() +warnings.warn( + "The pageurl tag library has been moved to wagtailcore_tags. " + "Use {% load wagtailcore_tags %} instead.", DeprecationWarning) -@register.simple_tag(takes_context=True) -def pageurl(context, page): - """ - Outputs a page's URL as relative (/foo/bar/) if it's within the same site as the - current page, or absolute (http://example.com/foo/bar/) if not. - """ - return page.relative_url(context['request'].site) - -@register.simple_tag(takes_context=True) -def slugurl(context, slug): - """Returns the URL for the page that has the given slug.""" - page = Page.objects.filter(slug=slug).first() - - if page: - return page.relative_url(context['request'].site) - else: - return None +from wagtail.wagtailcore.templatetags.wagtailcore_tags import register, pageurl diff --git a/wagtail/wagtailcore/templatetags/rich_text.py b/wagtail/wagtailcore/templatetags/rich_text.py index d3bc64fdd..c1db48250 100644 --- a/wagtail/wagtailcore/templatetags/rich_text.py +++ b/wagtail/wagtailcore/templatetags/rich_text.py @@ -1,11 +1,8 @@ -from django import template -from django.utils.safestring import mark_safe +import warnings -from wagtail.wagtailcore.rich_text import expand_db_html - -register = template.Library() +warnings.warn( + "The rich_text tag library has been moved to wagtailcore_tags. " + "Use {% load wagtailcore_tags %} instead.", DeprecationWarning) -@register.filter -def richtext(value): - return mark_safe('
    ' + expand_db_html(value) + '
    ') +from wagtail.wagtailcore.templatetags.wagtailcore_tags import register, richtext diff --git a/wagtail/wagtailcore/templatetags/wagtailcore_tags.py b/wagtail/wagtailcore/templatetags/wagtailcore_tags.py new file mode 100644 index 000000000..5e137c7a2 --- /dev/null +++ b/wagtail/wagtailcore/templatetags/wagtailcore_tags.py @@ -0,0 +1,32 @@ +from django import template +from django.utils.safestring import mark_safe + +from wagtail.wagtailcore.models import Page +from wagtail.wagtailcore.rich_text import expand_db_html + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def pageurl(context, page): + """ + Outputs a page's URL as relative (/foo/bar/) if it's within the same site as the + current page, or absolute (http://example.com/foo/bar/) if not. + """ + return page.relative_url(context['request'].site) + + +@register.simple_tag(takes_context=True) +def slugurl(context, slug): + """Returns the URL for the page that has the given slug.""" + page = Page.objects.filter(slug=slug).first() + + if page: + return page.relative_url(context['request'].site) + else: + return None + + +@register.filter +def richtext(value): + return mark_safe('
    ' + expand_db_html(value) + '
    ') diff --git a/wagtail/wagtailcore/tests/test_management_commands.py b/wagtail/wagtailcore/tests/test_management_commands.py index 787b808b5..ad1aa33aa 100644 --- a/wagtail/wagtailcore/tests/test_management_commands.py +++ b/wagtail/wagtailcore/tests/test_management_commands.py @@ -1,12 +1,13 @@ -from StringIO import StringIO +from datetime import timedelta -from django.test import TestCase, Client -from django.http import HttpRequest, Http404 +from six import StringIO + +from django.test import TestCase from django.core import management -from django.contrib.auth.models import User +from django.utils import timezone -from wagtail.wagtailcore.models import Page, Site, UserPagePermissionsProxy -from wagtail.tests.models import EventPage, EventIndex, SimplePage +from wagtail.wagtailcore.models import Page, PageRevision +from wagtail.tests.models import SimplePage class TestFixTreeCommand(TestCase): @@ -87,3 +88,107 @@ class TestReplaceTextCommand(TestCase): # Check that its now about easter self.assertEqual(Page.objects.get(url_path='/home/events/christmas/').title, "Easter") + + +class TestPublishScheduledPagesCommand(TestCase): + def setUp(self): + # Find root page + self.root_page = Page.objects.get(id=2) + + def test_go_live_page_will_be_published(self): + page = SimplePage( + title="Hello world!", + slug="hello-world", + live=False, + go_live_at=timezone.now() - timedelta(days=1), + ) + self.root_page.add_child(instance=page) + + page.save_revision(approved_go_live_at=timezone.now() - timedelta(days=1)) + + p = Page.objects.get(slug='hello-world') + self.assertFalse(p.live) + self.assertTrue(PageRevision.objects.filter(page=p).exclude(approved_go_live_at__isnull=True).exists()) + + management.call_command('publish_scheduled_pages') + + p = Page.objects.get(slug='hello-world') + self.assertTrue(p.live) + self.assertFalse(PageRevision.objects.filter(page=p).exclude(approved_go_live_at__isnull=True).exists()) + + def test_future_go_live_page_will_not_be_published(self): + page = SimplePage( + title="Hello world!", + slug="hello-world", + live=False, + go_live_at=timezone.now() + timedelta(days=1), + ) + self.root_page.add_child(instance=page) + + page.save_revision(approved_go_live_at=timezone.now() - timedelta(days=1)) + + p = Page.objects.get(slug='hello-world') + self.assertFalse(p.live) + self.assertTrue(PageRevision.objects.filter(page=p).exclude(approved_go_live_at__isnull=True).exists()) + + management.call_command('publish_scheduled_pages') + + p = Page.objects.get(slug='hello-world') + self.assertFalse(p.live) + self.assertTrue(PageRevision.objects.filter(page=p).exclude(approved_go_live_at__isnull=True).exists()) + + def test_expired_page_will_be_unpublished(self): + page = SimplePage( + title="Hello world!", + slug="hello-world", + live=True, + expire_at=timezone.now() - timedelta(days=1), + ) + self.root_page.add_child(instance=page) + + p = Page.objects.get(slug='hello-world') + self.assertTrue(p.live) + + management.call_command('publish_scheduled_pages') + + p = Page.objects.get(slug='hello-world') + self.assertFalse(p.live) + self.assertTrue(p.expired) + + def test_future_expired_page_will_not_be_unpublished(self): + page = SimplePage( + title="Hello world!", + slug="hello-world", + live=True, + expire_at=timezone.now() + timedelta(days=1), + ) + self.root_page.add_child(instance=page) + + p = Page.objects.get(slug='hello-world') + self.assertTrue(p.live) + + management.call_command('publish_scheduled_pages') + + p = Page.objects.get(slug='hello-world') + self.assertTrue(p.live) + self.assertFalse(p.expired) + + def test_expired_pages_are_dropped_from_mod_queue(self): + page = SimplePage( + title="Hello world!", + slug="hello-world", + live=False, + expire_at=timezone.now() - timedelta(days=1), + ) + self.root_page.add_child(instance=page) + + page.save_revision(submitted_for_moderation=True) + + p = Page.objects.get(slug='hello-world') + self.assertFalse(p.live) + self.assertTrue(PageRevision.objects.filter(page=p, submitted_for_moderation=True).exists()) + + management.call_command('publish_scheduled_pages') + + p = Page.objects.get(slug='hello-world') + self.assertFalse(PageRevision.objects.filter(page=p, submitted_for_moderation=True).exists()) diff --git a/wagtail/wagtailcore/tests/test_page_model.py b/wagtail/wagtailcore/tests/test_page_model.py index 1b4ce76e8..da9fc6174 100644 --- a/wagtail/wagtailcore/tests/test_page_model.py +++ b/wagtail/wagtailcore/tests/test_page_model.py @@ -1,39 +1,101 @@ -from StringIO import StringIO - from django.test import TestCase, Client from django.http import HttpRequest, Http404 -from django.core import management -from django.contrib.auth.models import User -from wagtail.wagtailcore.models import Page, Site, UserPagePermissionsProxy +from wagtail.wagtailcore.models import Page, Site from wagtail.tests.models import EventPage, EventIndex, SimplePage +class TestSiteRouting(TestCase): + fixtures = ['test.json'] + + def setUp(self): + self.default_site = Site.objects.get(is_default_site=True) + events_page = Page.objects.get(url_path='/home/events/') + about_page = Page.objects.get(url_path='/home/about-us/') + self.events_site = Site.objects.create(hostname='events.example.com', root_page=events_page) + self.alternate_port_events_site = Site.objects.create(hostname='events.example.com', root_page=events_page, port='8765') + self.about_site = Site.objects.create(hostname='about.example.com', root_page=about_page) + self.unrecognised_port = '8000' + self.unrecognised_hostname = 'unknown.site.com' + + def test_no_host_header_routes_to_default_site(self): + # requests without a Host: header should be directed to the default site + request = HttpRequest() + request.path = '/' + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_valid_headers_route_to_specific_site(self): + # requests with a known Host: header should be directed to the specific site + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.events_site.hostname + request.META['SERVER_PORT'] = self.events_site.port + self.assertEqual(Site.find_for_request(request), self.events_site) + + def test_ports_in_request_headers_are_respected(self): + # ports in the Host: header should be respected + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.alternate_port_events_site.hostname + request.META['SERVER_PORT'] = self.alternate_port_events_site.port + self.assertEqual(Site.find_for_request(request), self.alternate_port_events_site) + + def test_unrecognised_host_header_routes_to_default_site(self): + # requests with an unrecognised Host: header should be directed to the default site + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.unrecognised_hostname + request.META['SERVER_PORT'] = '80' + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_unrecognised_port_and_default_host_routes_to_default_site(self): + # requests to the default host on an unrecognised port should be directed to the default site + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.default_site.hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_unrecognised_port_and_unrecognised_host_routes_to_default_site(self): + # requests with an unrecognised Host: header _and_ an unrecognised port + # hould be directed to the default site + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.unrecognised_hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_unrecognised_port_on_known_hostname_routes_there_if_no_ambiguity(self): + # requests on an unrecognised port should be directed to the site with + # matching hostname if there is no ambiguity + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.about_site.hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.about_site) + + def test_unrecognised_port_on_known_hostname_routes_to_default_site_if_ambiguity(self): + # requests on an unrecognised port should be directed to the default + # site, even if their hostname (but not port) matches more than one + # other entry + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.events_site.hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_port_in_http_host_header_is_ignored(self): + # port in the HTTP_HOST header is ignored + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = "%s:%s" % (self.events_site.hostname, self.events_site.port) + request.META['SERVER_PORT'] = self.alternate_port_events_site.port + self.assertEqual(Site.find_for_request(request), self.alternate_port_events_site) + + class TestRouting(TestCase): fixtures = ['test.json'] - def test_find_site_for_request(self): - default_site = Site.objects.get(is_default_site=True) - events_page = Page.objects.get(url_path='/home/events/') - events_site = Site.objects.create(hostname='events.example.com', root_page=events_page) - - # requests without a Host: header should be directed to the default site - request = HttpRequest() - request.path = '/' - self.assertEqual(Site.find_for_request(request), default_site) - - # requests with a known Host: header should be directed to the specific site - request = HttpRequest() - request.path = '/' - request.META['HTTP_HOST'] = 'events.example.com' - self.assertEqual(Site.find_for_request(request), events_site) - - # requests with an unrecognised Host: header should be directed to the default site - request = HttpRequest() - request.path = '/' - request.META['HTTP_HOST'] = 'unknown.example.com' - self.assertEqual(Site.find_for_request(request), default_site) - def test_urls(self): default_site = Site.objects.get(is_default_site=True) homepage = Page.objects.get(url_path='/home/') @@ -226,6 +288,27 @@ class TestMovePage(TestCase): self.assertEqual(christmas.url_path, '/home/about-us/events/christmas/') +class TestPrevNextSiblings(TestCase): + fixtures = ['test.json'] + + def test_get_next_siblings(self): + christmas_event = Page.objects.get(url_path='/home/events/christmas/') + self.assertTrue(christmas_event.get_next_siblings().filter(url_path='/home/events/final-event/').exists()) + + def test_get_next_siblings_inclusive(self): + christmas_event = Page.objects.get(url_path='/home/events/christmas/') + + # First element must always be the current page + self.assertEqual(christmas_event.get_next_siblings(inclusive=True).first(), christmas_event) + + def test_get_prev_siblings(self): + final_event = Page.objects.get(url_path='/home/events/final-event/') + self.assertTrue(final_event.get_prev_siblings().filter(url_path='/home/events/christmas/').exists()) + + # First element must always be the current page + self.assertEqual(final_event.get_prev_siblings(inclusive=True).first(), final_event) + + class TestCopyPage(TestCase): fixtures = ['test.json'] diff --git a/wagtail/wagtailcore/tests/test_page_permissions.py b/wagtail/wagtailcore/tests/test_page_permissions.py index dbe35e39b..be4797d3d 100644 --- a/wagtail/wagtailcore/tests/test_page_permissions.py +++ b/wagtail/wagtailcore/tests/test_page_permissions.py @@ -1,12 +1,8 @@ -from StringIO import StringIO - -from django.test import TestCase, Client -from django.http import HttpRequest, Http404 -from django.core import management +from django.test import TestCase from django.contrib.auth.models import User -from wagtail.wagtailcore.models import Page, Site, UserPagePermissionsProxy -from wagtail.tests.models import EventPage, EventIndex, SimplePage +from wagtail.wagtailcore.models import Page, UserPagePermissionsProxy +from wagtail.tests.models import EventPage class TestPagePermission(TestCase): @@ -176,13 +172,26 @@ class TestPagePermission(TestCase): unpublished_event_page = EventPage.objects.get(url_path='/home/events/tentative-unpublished-event/') someone_elses_event_page = EventPage.objects.get(url_path='/home/events/someone-elses-event/') - editable_pages = UserPagePermissionsProxy(event_editor).editable_pages() + user_perms = UserPagePermissionsProxy(event_editor) + editable_pages = user_perms.editable_pages() + can_edit_pages = user_perms.can_edit_pages() + publishable_pages = user_perms.publishable_pages() + can_publish_pages = user_perms.can_publish_pages() self.assertFalse(editable_pages.filter(id=homepage.id).exists()) self.assertTrue(editable_pages.filter(id=christmas_page.id).exists()) self.assertTrue(editable_pages.filter(id=unpublished_event_page.id).exists()) self.assertFalse(editable_pages.filter(id=someone_elses_event_page.id).exists()) + self.assertTrue(can_edit_pages) + + self.assertFalse(publishable_pages.filter(id=homepage.id).exists()) + self.assertFalse(publishable_pages.filter(id=christmas_page.id).exists()) + self.assertFalse(publishable_pages.filter(id=unpublished_event_page.id).exists()) + self.assertFalse(publishable_pages.filter(id=someone_elses_event_page.id).exists()) + + self.assertFalse(can_publish_pages) + def test_editable_pages_for_user_with_edit_permission(self): event_moderator = User.objects.get(username='eventmoderator') homepage = Page.objects.get(url_path='/home/') @@ -190,13 +199,26 @@ class TestPagePermission(TestCase): unpublished_event_page = EventPage.objects.get(url_path='/home/events/tentative-unpublished-event/') someone_elses_event_page = EventPage.objects.get(url_path='/home/events/someone-elses-event/') - editable_pages = UserPagePermissionsProxy(event_moderator).editable_pages() + user_perms = UserPagePermissionsProxy(event_moderator) + editable_pages = user_perms.editable_pages() + can_edit_pages = user_perms.can_edit_pages() + publishable_pages = user_perms.publishable_pages() + can_publish_pages = user_perms.can_publish_pages() self.assertFalse(editable_pages.filter(id=homepage.id).exists()) self.assertTrue(editable_pages.filter(id=christmas_page.id).exists()) self.assertTrue(editable_pages.filter(id=unpublished_event_page.id).exists()) self.assertTrue(editable_pages.filter(id=someone_elses_event_page.id).exists()) + self.assertTrue(can_edit_pages) + + self.assertFalse(publishable_pages.filter(id=homepage.id).exists()) + self.assertTrue(publishable_pages.filter(id=christmas_page.id).exists()) + self.assertTrue(publishable_pages.filter(id=unpublished_event_page.id).exists()) + self.assertTrue(publishable_pages.filter(id=someone_elses_event_page.id).exists()) + + self.assertTrue(can_publish_pages) + def test_editable_pages_for_inactive_user(self): user = User.objects.get(username='inactiveuser') homepage = Page.objects.get(url_path='/home/') @@ -204,13 +226,26 @@ class TestPagePermission(TestCase): unpublished_event_page = EventPage.objects.get(url_path='/home/events/tentative-unpublished-event/') someone_elses_event_page = EventPage.objects.get(url_path='/home/events/someone-elses-event/') - editable_pages = UserPagePermissionsProxy(user).editable_pages() + user_perms = UserPagePermissionsProxy(user) + editable_pages = user_perms.editable_pages() + can_edit_pages = user_perms.can_edit_pages() + publishable_pages = user_perms.publishable_pages() + can_publish_pages = user_perms.can_publish_pages() self.assertFalse(editable_pages.filter(id=homepage.id).exists()) self.assertFalse(editable_pages.filter(id=christmas_page.id).exists()) self.assertFalse(editable_pages.filter(id=unpublished_event_page.id).exists()) self.assertFalse(editable_pages.filter(id=someone_elses_event_page.id).exists()) + self.assertFalse(can_edit_pages) + + self.assertFalse(publishable_pages.filter(id=homepage.id).exists()) + self.assertFalse(publishable_pages.filter(id=christmas_page.id).exists()) + self.assertFalse(publishable_pages.filter(id=unpublished_event_page.id).exists()) + self.assertFalse(publishable_pages.filter(id=someone_elses_event_page.id).exists()) + + self.assertFalse(can_publish_pages) + def test_editable_pages_for_superuser(self): user = User.objects.get(username='superuser') homepage = Page.objects.get(url_path='/home/') @@ -218,9 +253,49 @@ class TestPagePermission(TestCase): unpublished_event_page = EventPage.objects.get(url_path='/home/events/tentative-unpublished-event/') someone_elses_event_page = EventPage.objects.get(url_path='/home/events/someone-elses-event/') - editable_pages = UserPagePermissionsProxy(user).editable_pages() + user_perms = UserPagePermissionsProxy(user) + editable_pages = user_perms.editable_pages() + can_edit_pages = user_perms.can_edit_pages() + publishable_pages = user_perms.publishable_pages() + can_publish_pages = user_perms.can_publish_pages() self.assertTrue(editable_pages.filter(id=homepage.id).exists()) self.assertTrue(editable_pages.filter(id=christmas_page.id).exists()) self.assertTrue(editable_pages.filter(id=unpublished_event_page.id).exists()) self.assertTrue(editable_pages.filter(id=someone_elses_event_page.id).exists()) + + self.assertTrue(can_edit_pages) + + self.assertTrue(publishable_pages.filter(id=homepage.id).exists()) + self.assertTrue(publishable_pages.filter(id=christmas_page.id).exists()) + self.assertTrue(publishable_pages.filter(id=unpublished_event_page.id).exists()) + self.assertTrue(publishable_pages.filter(id=someone_elses_event_page.id).exists()) + + self.assertTrue(can_publish_pages) + + def test_editable_pages_for_non_editing_user(self): + user = User.objects.get(username='admin_only_user') + homepage = Page.objects.get(url_path='/home/') + christmas_page = EventPage.objects.get(url_path='/home/events/christmas/') + unpublished_event_page = EventPage.objects.get(url_path='/home/events/tentative-unpublished-event/') + someone_elses_event_page = EventPage.objects.get(url_path='/home/events/someone-elses-event/') + + user_perms = UserPagePermissionsProxy(user) + editable_pages = user_perms.editable_pages() + can_edit_pages = user_perms.can_edit_pages() + publishable_pages = user_perms.publishable_pages() + can_publish_pages = user_perms.can_publish_pages() + + self.assertFalse(editable_pages.filter(id=homepage.id).exists()) + self.assertFalse(editable_pages.filter(id=christmas_page.id).exists()) + self.assertFalse(editable_pages.filter(id=unpublished_event_page.id).exists()) + self.assertFalse(editable_pages.filter(id=someone_elses_event_page.id).exists()) + + self.assertFalse(can_edit_pages) + + self.assertFalse(publishable_pages.filter(id=homepage.id).exists()) + self.assertFalse(publishable_pages.filter(id=christmas_page.id).exists()) + self.assertFalse(publishable_pages.filter(id=unpublished_event_page.id).exists()) + self.assertFalse(publishable_pages.filter(id=someone_elses_event_page.id).exists()) + + self.assertFalse(can_publish_pages) diff --git a/wagtail/wagtailcore/tests/test_page_queryset.py b/wagtail/wagtailcore/tests/test_page_queryset.py index 06f2c3e21..bc07f0fba 100644 --- a/wagtail/wagtailcore/tests/test_page_queryset.py +++ b/wagtail/wagtailcore/tests/test_page_queryset.py @@ -1,12 +1,7 @@ -from StringIO import StringIO +from django.test import TestCase -from django.test import TestCase, Client -from django.http import HttpRequest, Http404 -from django.core import management -from django.contrib.auth.models import User - -from wagtail.wagtailcore.models import Page, Site, UserPagePermissionsProxy -from wagtail.tests.models import EventPage, EventIndex, SimplePage +from wagtail.wagtailcore.models import Page +from wagtail.tests.models import EventPage class TestPageQuerySet(TestCase): @@ -34,6 +29,27 @@ class TestPageQuerySet(TestCase): event = Page.objects.get(url_path='/home/events/someone-elses-event/') self.assertTrue(pages.filter(id=event.id).exists()) + def test_in_menu(self): + pages = Page.objects.in_menu() + + # All pages must be be in the menus + for page in pages: + self.assertTrue(page.show_in_menus) + + # Check that the events index is in the results + events_index = Page.objects.get(url_path='/home/events/') + self.assertTrue(pages.filter(id=events_index.id).exists()) + + def test_not_in_menu(self): + pages = Page.objects.not_in_menu() + + # All pages must not be in menus + for page in pages: + self.assertFalse(page.show_in_menus) + + # Check that the root page is in the results + self.assertTrue(pages.filter(id=1).exists()) + def test_page(self): homepage = Page.objects.get(url_path='/home/') pages = Page.objects.page(homepage) diff --git a/wagtail/wagtailcore/tests/test_rich_text.py b/wagtail/wagtailcore/tests/test_rich_text.py new file mode 100644 index 000000000..9c98c6d3f --- /dev/null +++ b/wagtail/wagtailcore/tests/test_rich_text.py @@ -0,0 +1,262 @@ +from mock import patch + +from django.test import TestCase + +from wagtail.wagtailcore.rich_text import ( + ImageEmbedHandler, + MediaEmbedHandler, + PageLinkHandler, + DocumentLinkHandler, + DbWhitelister, + extract_attrs, + expand_db_html +) +from bs4 import BeautifulSoup + + +class TestImageEmbedHandler(TestCase): + fixtures = ['wagtail/tests/fixtures/test.json'] + + def test_get_db_attributes(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.b + result = ImageEmbedHandler.get_db_attributes(tag) + self.assertEqual(result, + {'alt': 'test-alt', + 'id': 'test-id', + 'format': 'test-format'}) + + def test_expand_db_attributes_page_does_not_exist(self): + result = ImageEmbedHandler.expand_db_attributes( + {'id': 0}, + False + ) + self.assertEqual(result, '') + + @patch('wagtail.wagtailimages.models.Image') + @patch('django.core.files.File') + def test_expand_db_attributes_not_for_editor(self, mock_file, mock_image): + result = ImageEmbedHandler.expand_db_attributes( + {'id': 1, + 'alt': 'test-alt', + 'format': 'left'}, + False + ) + self.assertIn('foo' + ) + tag = soup.b + result = MediaEmbedHandler.get_db_attributes(tag) + self.assertEqual(result, + {'url': 'test-url'}) + + @patch('wagtail.wagtailembeds.embeds.oembed') + def test_expand_db_attributes_for_editor(self, oembed): + oembed.return_value = { + 'title': 'test title', + 'author_name': 'test author name', + 'provider_name': 'test provider name', + 'type': 'test type', + 'thumbnail_url': 'test thumbnail url', + 'width': 'test width', + 'height': 'test height', + 'html': 'test html' + } + result = MediaEmbedHandler.expand_db_attributes( + {'url': 'http://www.youtube.com/watch/'}, + True + ) + self.assertIn('
    ', result) + self.assertIn('

    test title

    ', result) + self.assertIn('

    URL: http://www.youtube.com/watch/

    ', result) + self.assertIn('

    Provider: test provider name

    ', result) + self.assertIn('

    Author: test author name

    ', result) + self.assertIn('test title', result) + + @patch('wagtail.wagtailembeds.embeds.oembed') + def test_expand_db_attributes_not_for_editor(self, oembed): + oembed.return_value = { + 'title': 'test title', + 'author_name': 'test author name', + 'provider_name': 'test provider name', + 'type': 'test type', + 'thumbnail_url': 'test thumbnail url', + 'width': 'test width', + 'height': 'test height', + 'html': 'test html' + } + result = MediaEmbedHandler.expand_db_attributes( + {'url': 'http://www.youtube.com/watch/'}, + False + ) + self.assertIn('test html', result) + + +class TestPageLinkHandler(TestCase): + fixtures = ['wagtail/tests/fixtures/test.json'] + + def test_get_db_attributes(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.a + result = PageLinkHandler.get_db_attributes(tag) + self.assertEqual(result, + {'id': 'test-id'}) + + def test_expand_db_attributes_page_does_not_exist(self): + result = PageLinkHandler.expand_db_attributes( + {'id': 0}, + False + ) + self.assertEqual(result, '') + + def test_expand_db_attributes_for_editor(self): + result = PageLinkHandler.expand_db_attributes( + {'id': 1}, + True + ) + self.assertEqual(result, + '') + + def test_expand_db_attributes_not_for_editor(self): + result = PageLinkHandler.expand_db_attributes( + {'id': 1}, + False + ) + self.assertEqual(result, '') + + +class TestDocumentLinkHandler(TestCase): + fixtures = ['wagtail/tests/fixtures/test.json'] + + def test_get_db_attributes(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.a + result = DocumentLinkHandler.get_db_attributes(tag) + self.assertEqual(result, + {'id': 'test-id'}) + + def test_expand_db_attributes_document_does_not_exist(self): + result = DocumentLinkHandler.expand_db_attributes( + {'id': 0}, + False + ) + self.assertEqual(result, '') + + def test_expand_db_attributes_for_editor(self): + result = DocumentLinkHandler.expand_db_attributes( + {'id': 1}, + True + ) + self.assertEqual(result, + '') + + def test_expand_db_attributes_not_for_editor(self): + result = DocumentLinkHandler.expand_db_attributes( + {'id': 1}, + False + ) + self.assertEqual(result, + '') + + +class TestDbWhiteLister(TestCase): + def test_clean_tag_node_div(self): + soup = BeautifulSoup( + '
    foo
    ' + ) + tag = soup.div + self.assertEqual(tag.name, 'div') + DbWhitelister.clean_tag_node(soup, tag) + self.assertEqual(tag.name, 'p') + + def test_clean_tag_node_with_data_embedtype(self): + soup = BeautifulSoup( + '

    foo

    ' + ) + tag = soup.p + DbWhitelister.clean_tag_node(soup, tag) + self.assertEqual(str(tag), + '

    ') + + def test_clean_tag_node_with_data_linktype(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.a + DbWhitelister.clean_tag_node(soup, tag) + self.assertEqual(str(tag), 'foo') + + def test_clean_tag_node(self): + soup = BeautifulSoup( + 'foo' + ) + tag = soup.a + DbWhitelister.clean_tag_node(soup, tag) + self.assertEqual(str(tag), 'foo') + + +class TestExtractAttrs(TestCase): + def test_extract_attr(self): + html = 'snowman' + result = extract_attrs(html) + self.assertEqual(result, {'foo': 'bar', 'baz': 'quux'}) + + +class TestExpandDbHtml(TestCase): + def test_expand_db_html_with_linktype(self): + html = 'foo' + result = expand_db_html(html) + self.assertEqual(result, 'foo') + + def test_expand_db_html_no_linktype(self): + html = 'foo' + result = expand_db_html(html) + self.assertEqual(result, 'foo') + + @patch('wagtail.wagtailembeds.embeds.oembed') + def test_expand_db_html_with_embed(self, oembed): + oembed.return_value = { + 'title': 'test title', + 'author_name': 'test author name', + 'provider_name': 'test provider name', + 'type': 'test type', + 'thumbnail_url': 'test thumbnail url', + 'width': 'test width', + 'height': 'test height', + 'html': 'test html' + } + html = '' + result = expand_db_html(html) + self.assertIn('test html', result) diff --git a/wagtail/wagtailcore/tests/test_whitelist.py b/wagtail/wagtailcore/tests/test_whitelist.py index 28b63d9fb..d96100382 100644 --- a/wagtail/wagtailcore/tests/test_whitelist.py +++ b/wagtail/wagtailcore/tests/test_whitelist.py @@ -1,4 +1,4 @@ -from bs4 import BeautifulSoup, NavigableString +from bs4 import BeautifulSoup from django.test import TestCase from wagtail.wagtailcore.whitelist import ( diff --git a/wagtail/wagtailcore/tests/tests.py b/wagtail/wagtailcore/tests/tests.py index d10474bba..5fd5aa802 100644 --- a/wagtail/wagtailcore/tests/tests.py +++ b/wagtail/wagtailcore/tests/tests.py @@ -1,12 +1,7 @@ -from StringIO import StringIO +from django.test import TestCase -from django.test import TestCase, Client -from django.http import HttpRequest, Http404 -from django.core import management -from django.contrib.auth.models import User - -from wagtail.wagtailcore.models import Page, Site, UserPagePermissionsProxy -from wagtail.tests.models import EventPage, EventIndex, SimplePage +from wagtail.wagtailcore.models import Page, Site +from wagtail.tests.models import SimplePage class TestPageUrlTags(TestCase): @@ -15,22 +10,25 @@ class TestPageUrlTags(TestCase): def test_pageurl_tag(self): response = self.client.get('/events/') self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Christmas') + self.assertContains(response, + 'Christmas') def test_slugurl_tag(self): response = self.client.get('/events/christmas/') self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Back to events index') + self.assertContains(response, + 'Back to events index') class TestIssue7(TestCase): """ - This tests for an issue where if a site root page was moved, all the page - urls in that site would change to None. + This tests for an issue where if a site root page was moved, all + the page urls in that site would change to None. - The issue was caused by the 'wagtail_site_root_paths' cache variable not being - cleared when a site root page was moved. Which left all the child pages - thinking that they are no longer in the site and return None as their url. + The issue was caused by the 'wagtail_site_root_paths' cache + variable not being cleared when a site root page was moved. Which + left all the child pages thinking that they are no longer in the + site and return None as their url. Fix: d6cce69a397d08d5ee81a8cbc1977ab2c9db2682 Discussion: https://github.com/torchbox/wagtail/issues/7 @@ -67,12 +65,13 @@ class TestIssue7(TestCase): class TestIssue157(TestCase): """ - This tests for an issue where if a site root pages slug was changed, all the page - urls in that site would change to None. + This tests for an issue where if a site root pages slug was + changed, all the page urls in that site would change to None. - The issue was caused by the 'wagtail_site_root_paths' cache variable not being - cleared when a site root page was changed. Which left all the child pages - thinking that they are no longer in the site and return None as their url. + The issue was caused by the 'wagtail_site_root_paths' cache + variable not being cleared when a site root page was changed. + Which left all the child pages thinking that they are no longer in + the site and return None as their url. Fix: d6cce69a397d08d5ee81a8cbc1977ab2c9db2682 Discussion: https://github.com/torchbox/wagtail/issues/157 diff --git a/wagtail/wagtailcore/whitelist.py b/wagtail/wagtailcore/whitelist.py index 9a5f67cf1..4aaff780d 100644 --- a/wagtail/wagtailcore/whitelist.py +++ b/wagtail/wagtailcore/whitelist.py @@ -2,8 +2,9 @@ A generic HTML whitelisting engine, designed to accommodate subclassing to override specific rules. """ +from six.moves.urllib.parse import urlparse + from bs4 import BeautifulSoup, NavigableString, Tag -from urlparse import urlparse ALLOWED_URL_SCHEMES = ['', 'http', 'https', 'ftp', 'mailto', 'tel'] @@ -28,7 +29,7 @@ def attribute_rule(allowed_attrs): * if the lookup returns a truthy value, keep the attribute; if falsy, drop it """ def fn(tag): - for attr, val in tag.attrs.items(): + for attr, val in list(tag.attrs.items()): rule = allowed_attrs.get(attr) if rule: if callable(rule): @@ -82,7 +83,7 @@ class Whitelister(object): attributes""" doc = BeautifulSoup(html, 'lxml') cls.clean_node(doc, doc) - return unicode(doc) + return doc.decode() @classmethod def clean_node(cls, doc, node): diff --git a/wagtail/wagtaildocs/migrations/0001_initial.py b/wagtail/wagtaildocs/migrations/0001_initial.py index fb3805eb5..6c501ef7f 100644 --- a/wagtail/wagtaildocs/migrations/0001_initial.py +++ b/wagtail/wagtaildocs/migrations/0001_initial.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import SchemaMigration @@ -15,8 +17,8 @@ class Migration(SchemaMigration): def forwards(self, orm): # Adding model 'Document' - db.create_table(u'wagtaildocs_document', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + db.create_table('wagtaildocs_document', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('title', self.gf('django.db.models.fields.CharField')(max_length=255)), ('file', self.gf('django.db.models.fields.files.FileField')(max_length=100)), ('created_at', self.gf('django.db.models.fields.DateTimeField') @@ -24,24 +26,24 @@ class Migration(SchemaMigration): ('uploaded_by_user', self.gf('django.db.models.fields.related.ForeignKey') (to=orm[AUTH_USER_MODEL], null=True, blank=True)), )) - db.send_create_signal(u'wagtaildocs', ['Document']) + db.send_create_signal('wagtaildocs', ['Document']) def backwards(self, orm): # Deleting model 'Document' - db.delete_table(u'wagtaildocs_document') + db.delete_table('wagtaildocs_document') models = { - u'auth.group': { + 'auth.group': { 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) }, AUTH_USER_MODEL: { @@ -49,31 +51,31 @@ class Migration(SchemaMigration): 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) }, - u'contenttypes.contenttype': { + 'contenttypes.contenttype': { 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - u'wagtaildocs.document': { + 'wagtaildocs.document': { 'Meta': {'object_name': 'Document'}, 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'uploaded_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) + 'uploaded_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) } } diff --git a/wagtail/wagtaildocs/migrations/0002_initial_data.py b/wagtail/wagtaildocs/migrations/0002_initial_data.py index a23f79c88..6b58875c3 100644 --- a/wagtail/wagtaildocs/migrations/0002_initial_data.py +++ b/wagtail/wagtaildocs/migrations/0002_initial_data.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import DataMigration @@ -16,11 +18,11 @@ class Migration(DataMigration): document_content_type, created = orm['contenttypes.ContentType'].objects.get_or_create( model='document', app_label='wagtaildocs', defaults={'name': 'document'}) add_permission, created = orm['auth.permission'].objects.get_or_create( - content_type=document_content_type, codename='add_document', defaults=dict(name=u'Can add document')) + content_type=document_content_type, codename='add_document', defaults=dict(name='Can add document')) change_permission, created = orm['auth.permission'].objects.get_or_create( - content_type=document_content_type, codename='change_document', defaults=dict(name=u'Can change document')) + content_type=document_content_type, codename='change_document', defaults=dict(name='Can change document')) delete_permission, created = orm['auth.permission'].objects.get_or_create( - content_type=document_content_type, codename='delete_document', defaults=dict(name=u'Can delete document')) + content_type=document_content_type, codename='delete_document', defaults=dict(name='Can delete document')) editors_group = orm['auth.group'].objects.get(name='Editors') editors_group.permissions.add(add_permission, change_permission, delete_permission) @@ -40,17 +42,17 @@ class Migration(DataMigration): moderators_group.permissions.remove(*document_permissions) models = { - u'auth.group': { + 'auth.group': { 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) }, AUTH_USER_MODEL: { @@ -58,31 +60,31 @@ class Migration(DataMigration): 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) }, - u'contenttypes.contenttype': { + 'contenttypes.contenttype': { 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - u'wagtaildocs.document': { + 'wagtaildocs.document': { 'Meta': {'object_name': 'Document'}, 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'uploaded_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) + 'uploaded_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['%s']" % AUTH_USER_MODEL, 'null': 'True', 'blank': 'True'}) } } diff --git a/wagtail/wagtaildocs/models.py b/wagtail/wagtaildocs/models.py index 2149320f7..a87b9c02b 100644 --- a/wagtail/wagtaildocs/models.py +++ b/wagtail/wagtaildocs/models.py @@ -9,10 +9,12 @@ from django.dispatch import Signal from django.core.urlresolvers import reverse from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import python_2_unicode_compatible from wagtail.wagtailadmin.taggable import TagSearchable +@python_2_unicode_compatible class Document(models.Model, TagSearchable): title = models.CharField(max_length=255, verbose_name=_('Title')) file = models.FileField(upload_to='documents' , verbose_name=_('File')) @@ -30,7 +32,7 @@ class Document(models.Model, TagSearchable): }, } - def __unicode__(self): + def __str__(self): return self.title @property diff --git a/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html b/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html index c4466d897..f9a221bb3 100644 --- a/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html +++ b/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html @@ -1,6 +1,6 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% load image_tags %} +{% load wagtailimages_tags %} {% block titletag %}{% trans "Add a document" %}{% endblock %} {% block bodyclass %}menu-documents{% endblock %} {% block extra_css %} diff --git a/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html b/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html index d27f16155..55734fbbd 100644 --- a/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html +++ b/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html @@ -1,6 +1,6 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% load image_tags %} +{% load wagtailimages_tags %} {% block titletag %}{% blocktrans with title=document.title %}Editing {{ title }}{% endblocktrans %}{% endblock %} {% block bodyclass %}menu-documents{% endblock %} {% block extra_css %} diff --git a/wagtail/wagtaildocs/tests.py b/wagtail/wagtaildocs/tests.py index a8420406b..79b799a36 100644 --- a/wagtail/wagtaildocs/tests.py +++ b/wagtail/wagtaildocs/tests.py @@ -1,10 +1,14 @@ +from six import b + from django.test import TestCase -from wagtail.wagtaildocs import models -from wagtail.tests.utils import WagtailTestUtils + from django.contrib.auth.models import User, Group, Permission from django.core.urlresolvers import reverse from django.core.files.base import ContentFile +from wagtail.wagtaildocs import models +from wagtail.tests.utils import WagtailTestUtils + # TODO: Test serve view @@ -112,7 +116,7 @@ class TestDocumentAddView(TestCase, WagtailTestUtils): def test_post(self): # Build a fake file - fake_file = ContentFile("A boring example document") + fake_file = ContentFile(b("A boring example document")) fake_file.name = 'test.txt' # Submit @@ -134,7 +138,7 @@ class TestDocumentEditView(TestCase, WagtailTestUtils): self.login() # Build a fake file - fake_file = ContentFile("A boring example document") + fake_file = ContentFile(b("A boring example document")) fake_file.name = 'test.txt' # Create a document to edit @@ -147,7 +151,7 @@ class TestDocumentEditView(TestCase, WagtailTestUtils): def test_post(self): # Build a fake file - fake_file = ContentFile("A boring example document") + fake_file = ContentFile(b("A boring example document")) fake_file.name = 'test.txt' # Submit title change @@ -272,7 +276,7 @@ class TestDocumentChooserUploadView(TestCase, WagtailTestUtils): def test_post(self): # Build a fake file - fake_file = ContentFile("A boring example document") + fake_file = ContentFile(b("A boring example document")) fake_file.name = 'test.txt' # Submit diff --git a/wagtail/wagtaildocs/wagtail_hooks.py b/wagtail/wagtaildocs/wagtail_hooks.py index 3ab1ad9bf..a6e00f8db 100644 --- a/wagtail/wagtaildocs/wagtail_hooks.py +++ b/wagtail/wagtaildocs/wagtail_hooks.py @@ -4,7 +4,7 @@ from django.core import urlresolvers from django.utils.html import format_html, format_html_join from django.utils.translation import ugettext_lazy as _ -from wagtail.wagtailadmin import hooks +from wagtail.wagtailcore import hooks from wagtail.wagtailadmin.menu import MenuItem from wagtail.wagtaildocs import admin_urls diff --git a/wagtail/wagtailembeds/embeds.py b/wagtail/wagtailembeds/embeds.py index 2ec974d8a..40bf48ec9 100644 --- a/wagtail/wagtailembeds/embeds.py +++ b/wagtail/wagtailembeds/embeds.py @@ -1,4 +1,6 @@ import sys +from datetime import datetime +import json try: from importlib import import_module @@ -6,13 +8,20 @@ except ImportError: # for Python 2.6, fall back on django.utils.importlib (deprecated as of Django 1.7) from django.utils.importlib import import_module + +# Needs to be imported like this to allow @patch to work in tests +from six.moves.urllib import request as urllib_request + +from six.moves.urllib.request import Request +from six.moves.urllib.error import URLError +from six.moves.urllib.parse import urlencode + from django.conf import settings -from datetime import datetime from django.utils import six + from wagtail.wagtailembeds.oembed_providers import get_oembed_provider from wagtail.wagtailembeds.models import Embed -import urllib2, urllib -import json + class EmbedNotFoundException(Exception): pass @@ -99,11 +108,11 @@ def oembed(url, max_width=None): params['maxwidth'] = max_width # Perform request - request = urllib2.Request(provider + '?' + urllib.urlencode(params)) + request = Request(provider + '?' + urlencode(params)) request.add_header('User-agent', 'Mozilla/5.0') try: - r = urllib2.urlopen(request) - except urllib2.URLError: + r = urllib_request.urlopen(request) + except URLError: raise EmbedNotFoundException oembed = json.loads(r.read()) diff --git a/wagtail/wagtailembeds/format.py b/wagtail/wagtailembeds/format.py index 1654be989..8a73ff524 100644 --- a/wagtail/wagtailembeds/format.py +++ b/wagtail/wagtailembeds/format.py @@ -1,6 +1,5 @@ from __future__ import division # Use true division -from django.utils.html import escape from django.template.loader import render_to_string from wagtail.wagtailembeds import get_embed diff --git a/wagtail/wagtailembeds/migrations/0001_initial.py b/wagtail/wagtailembeds/migrations/0001_initial.py index 7f782a3b5..ba5d5bbe0 100644 --- a/wagtail/wagtailembeds/migrations/0001_initial.py +++ b/wagtail/wagtailembeds/migrations/0001_initial.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import SchemaMigration @@ -13,8 +15,8 @@ class Migration(SchemaMigration): def forwards(self, orm): # Adding model 'Embed' - db.create_table(u'wagtailembeds_embed', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + db.create_table('wagtailembeds_embed', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('url', self.gf('django.db.models.fields.URLField')(max_length=200)), ('max_width', self.gf('django.db.models.fields.SmallIntegerField') (null=True, blank=True)), @@ -30,24 +32,24 @@ class Migration(SchemaMigration): ('last_updated', self.gf('django.db.models.fields.DateTimeField') (auto_now=True, blank=True)), )) - db.send_create_signal(u'wagtailembeds', ['Embed']) + db.send_create_signal('wagtailembeds', ['Embed']) # Adding unique constraint on 'Embed', fields ['url', 'max_width'] - db.create_unique(u'wagtailembeds_embed', ['url', 'max_width']) + db.create_unique('wagtailembeds_embed', ['url', 'max_width']) def backwards(self, orm): # Removing unique constraint on 'Embed', fields ['url', 'max_width'] - db.delete_unique(u'wagtailembeds_embed', ['url', 'max_width']) + db.delete_unique('wagtailembeds_embed', ['url', 'max_width']) # Deleting model 'Embed' - db.delete_table(u'wagtailembeds_embed') + db.delete_table('wagtailembeds_embed') models = { - u'wagtailembeds.embed': { + 'wagtailembeds.embed': { 'Meta': {'unique_together': "(('url', 'max_width'),)", 'object_name': 'Embed'}, 'height': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), 'html': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'last_updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 'max_width': ('django.db.models.fields.SmallIntegerField', [], {'null': 'True', 'blank': 'True'}), 'thumbnail_url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}), diff --git a/wagtail/wagtailembeds/migrations/0002_auto__add_field_embed_author_name__add_field_embed_provider_name.py b/wagtail/wagtailembeds/migrations/0002_auto__add_field_embed_author_name__add_field_embed_provider_name.py index 7da581d21..dd05de09d 100644 --- a/wagtail/wagtailembeds/migrations/0002_auto__add_field_embed_author_name__add_field_embed_provider_name.py +++ b/wagtail/wagtailembeds/migrations/0002_auto__add_field_embed_author_name__add_field_embed_provider_name.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import SchemaMigration @@ -9,31 +11,31 @@ class Migration(SchemaMigration): def forwards(self, orm): # Adding field 'Embed.author_name' - db.add_column(u'wagtailembeds_embed', 'author_name', + db.add_column('wagtailembeds_embed', 'author_name', self.gf('django.db.models.fields.TextField')(default='', blank=True), keep_default=False) # Adding field 'Embed.provider_name' - db.add_column(u'wagtailembeds_embed', 'provider_name', + db.add_column('wagtailembeds_embed', 'provider_name', self.gf('django.db.models.fields.TextField')(default='', blank=True), keep_default=False) def backwards(self, orm): # Deleting field 'Embed.author_name' - db.delete_column(u'wagtailembeds_embed', 'author_name') + db.delete_column('wagtailembeds_embed', 'author_name') # Deleting field 'Embed.provider_name' - db.delete_column(u'wagtailembeds_embed', 'provider_name') + db.delete_column('wagtailembeds_embed', 'provider_name') models = { - u'wagtailembeds.embed': { + 'wagtailembeds.embed': { 'Meta': {'unique_together': "(('url', 'max_width'),)", 'object_name': 'Embed'}, 'author_name': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'height': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), 'html': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'last_updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 'max_width': ('django.db.models.fields.SmallIntegerField', [], {'null': 'True', 'blank': 'True'}), 'provider_name': ('django.db.models.fields.TextField', [], {'blank': 'True'}), diff --git a/wagtail/wagtailembeds/models.py b/wagtail/wagtailembeds/models.py index c147860f0..4f61c19d7 100644 --- a/wagtail/wagtailembeds/models.py +++ b/wagtail/wagtailembeds/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils.encoding import python_2_unicode_compatible EMBED_TYPES = ( @@ -9,6 +10,7 @@ EMBED_TYPES = ( ) +@python_2_unicode_compatible class Embed(models.Model): url = models.URLField() max_width = models.SmallIntegerField(null=True, blank=True) @@ -25,5 +27,5 @@ class Embed(models.Model): class Meta: unique_together = ('url', 'max_width') - def __unicode__(self): + def __str__(self): return self.url diff --git a/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html b/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html index b914f9c6b..fbc28c425 100644 --- a/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html +++ b/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html @@ -1,4 +1,4 @@ -{% load image_tags %} +{% load wagtailimages_tags %} {% load i18n %} {% trans "Insert embed" as ins_emb_str %} {% include "wagtailadmin/shared/header.html" with title=ins_emb_str merged=1 %} diff --git a/wagtail/wagtailembeds/templatetags/embed_filters.py b/wagtail/wagtailembeds/templatetags/embed_filters.py index d916c0ffb..fcc4c2773 100644 --- a/wagtail/wagtailembeds/templatetags/embed_filters.py +++ b/wagtail/wagtailembeds/templatetags/embed_filters.py @@ -1,24 +1,8 @@ -from django import template -from django.utils.safestring import mark_safe +import warnings -from wagtail.wagtailembeds import get_embed +warnings.warn( + "The embed_filters tag library has been moved to wagtailembeds_tags. " + "Use {% load wagtailembeds_tags %} instead.", DeprecationWarning) -register = template.Library() - - -@register.filter -def embed(url, max_width=None): - embed = get_embed(url, max_width=max_width) - try: - if embed is not None: - return mark_safe(embed.html) - else: - return '' - except: - return '' - - -@register.filter -def embedly(url, max_width=None): - return embed(url, max_width) +from wagtail.wagtailembeds.templatetags.wagtailembeds_tags import register, embed, embedly diff --git a/wagtail/wagtailembeds/templatetags/wagtailembeds_tags.py b/wagtail/wagtailembeds/templatetags/wagtailembeds_tags.py new file mode 100644 index 000000000..1ad63cddf --- /dev/null +++ b/wagtail/wagtailembeds/templatetags/wagtailembeds_tags.py @@ -0,0 +1,30 @@ +import warnings + +from django import template +from django.utils.safestring import mark_safe + +from wagtail.wagtailembeds import get_embed + + +register = template.Library() + + +@register.filter +def embed(url, max_width=None): + embed = get_embed(url, max_width=max_width) + try: + if embed is not None: + return mark_safe(embed.html) + else: + return '' + except: + return '' + + +@register.filter +def embedly(url, max_width=None): + warnings.warn( + "The 'embedly' filter has been renamed. " + "Use 'embed' instead.", DeprecationWarning) + + return embed(url, max_width) diff --git a/wagtail/wagtailembeds/tests.py b/wagtail/wagtailembeds/tests.py index 09ad3c902..73f6a7f4b 100644 --- a/wagtail/wagtailembeds/tests.py +++ b/wagtail/wagtailembeds/tests.py @@ -1,5 +1,8 @@ +import six.moves.urllib.request +from six.moves.urllib.error import URLError + from mock import patch -import urllib2 +import warnings try: import embedly @@ -7,6 +10,7 @@ try: except ImportError: no_embedly = True +from django import template from django.test import TestCase from wagtail.tests.utils import WagtailTestUtils, unittest @@ -18,7 +22,7 @@ from wagtail.wagtailembeds.embeds import ( AccessDeniedEmbedlyException, ) from wagtail.wagtailembeds.embeds import embedly as wagtail_embedly, oembed as wagtail_oembed - +from wagtail.wagtailembeds.templatetags.wagtailembeds_tags import embed as embed_filter, embedly as embedly_filter class TestEmbeds(TestCase): @@ -216,12 +220,12 @@ class TestOembed(TestCase): self.assertRaises(EmbedNotFoundException, wagtail_oembed, "foo") def test_oembed_invalid_request(self): - config = {'side_effect': urllib2.URLError('foo')} - with patch.object(urllib2, 'urlopen', **config) as urlopen: + config = {'side_effect': URLError('foo')} + with patch.object(six.moves.urllib.request, 'urlopen', **config) as urlopen: self.assertRaises(EmbedNotFoundException, wagtail_oembed, "http://www.youtube.com/watch/") - @patch('urllib2.urlopen') + @patch('six.moves.urllib.request.urlopen') @patch('json.loads') def test_oembed_photo_request(self, loads, urlopen) : urlopen.return_value = self.dummy_response @@ -232,7 +236,7 @@ class TestOembed(TestCase): self.assertEqual(result['html'], '') loads.assert_called_with("foo") - @patch('urllib2.urlopen') + @patch('six.moves.urllib.request.urlopen') @patch('json.loads') def test_oembed_return_values(self, loads, urlopen): urlopen.return_value = self.dummy_response @@ -258,3 +262,88 @@ class TestOembed(TestCase): 'height': 'test_height', 'html': 'test_html' }) + + +class TestEmbedFilter(TestCase): + def setUp(self): + class DummyResponse(object): + def read(self): + return "foo" + self.dummy_response = DummyResponse() + + @patch('six.moves.urllib.request.urlopen') + @patch('json.loads') + def test_valid_embed(self, loads, urlopen): + urlopen.return_value = self.dummy_response + loads.return_value = {'type': 'photo', + 'url': 'http://www.example.com'} + result = embed_filter('http://www.youtube.com/watch/') + self.assertEqual(result, '') + + @patch('six.moves.urllib.request.urlopen') + @patch('json.loads') + def test_render_filter(self, loads, urlopen): + urlopen.return_value = self.dummy_response + loads.return_value = {'type': 'photo', + 'url': 'http://www.example.com'} + temp = template.Template('{% load wagtailembeds_tags %}{{ "http://www.youtube.com/watch/"|embed }}') + context = template.Context() + result = temp.render(context) + self.assertEqual(result, '') + + @patch('six.moves.urllib.request.urlopen') + @patch('json.loads') + def test_render_filter_nonexistent_type(self, loads, urlopen): + urlopen.return_value = self.dummy_response + loads.return_value = {'type': 'foo', + 'url': 'http://www.example.com'} + temp = template.Template('{% load wagtailembeds_tags %}{{ "http://www.youtube.com/watch/"|embed }}') + context = template.Context() + result = temp.render(context) + self.assertEqual(result, '') + + +class TestEmbedlyFilter(TestEmbedFilter): + def setUp(self): + class DummyResponse(object): + def read(self): + return "foo" + self.dummy_response = DummyResponse() + + @patch('six.moves.urllib.request.urlopen') + @patch('json.loads') + def test_valid_embed(self, loads, urlopen): + urlopen.return_value = self.dummy_response + loads.return_value = {'type': 'photo', + 'url': 'http://www.example.com'} + result = embedly_filter('http://www.youtube.com/watch/') + self.assertEqual(result, '') + + @patch('six.moves.urllib.request.urlopen') + @patch('json.loads') + def test_render_filter(self, loads, urlopen): + urlopen.return_value = self.dummy_response + loads.return_value = {'type': 'photo', + 'url': 'http://www.example.com'} + temp = template.Template('{% load wagtailembeds_tags %}{{ "http://www.youtube.com/watch/"|embedly }}') + context = template.Context() + with warnings.catch_warnings(record=True) as w: + result = temp.render(context) + + # Check that a DeprecationWarning has been triggered + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + self.assertTrue("The 'embedly' filter has been renamed. Use 'embed' instead" in str(w[-1].message)) + + self.assertEqual(result, '') + + @patch('six.moves.urllib.request.urlopen') + @patch('json.loads') + def test_render_filter_nonexistent_type(self, loads, urlopen): + urlopen.return_value = self.dummy_response + loads.return_value = {'type': 'foo', + 'url': 'http://www.example.com'} + temp = template.Template('{% load wagtailembeds_tags %}{{ "http://www.youtube.com/watch/"|embedly }}') + context = template.Context() + result = temp.render(context) + self.assertEqual(result, '') diff --git a/wagtail/wagtailembeds/views/chooser.py b/wagtail/wagtailembeds/views/chooser.py index b364d83b7..5892ed91b 100644 --- a/wagtail/wagtailembeds/views/chooser.py +++ b/wagtail/wagtailembeds/views/chooser.py @@ -1,5 +1,4 @@ from django.forms.util import ErrorList -from django.conf import settings from django.utils.translation import ugettext as _ from wagtail.wagtailadmin.modal_workflow import render_modal_workflow diff --git a/wagtail/wagtailembeds/wagtail_hooks.py b/wagtail/wagtailembeds/wagtail_hooks.py index 89c5e66a5..f9ea650a6 100644 --- a/wagtail/wagtailembeds/wagtail_hooks.py +++ b/wagtail/wagtailembeds/wagtail_hooks.py @@ -3,7 +3,7 @@ from django.conf.urls import include, url from django.core import urlresolvers from django.utils.html import format_html -from wagtail.wagtailadmin import hooks +from wagtail.wagtailcore import hooks from wagtail.wagtailembeds import urls diff --git a/wagtail/wagtailforms/forms.py b/wagtail/wagtailforms/forms.py index f17023a77..4f874133a 100644 --- a/wagtail/wagtailforms/forms.py +++ b/wagtail/wagtailforms/forms.py @@ -8,22 +8,9 @@ class BaseForm(django.forms.Form): return super(BaseForm, self).__init__(*args, **kwargs) -class FormBuilder(): - formfields = SortedDict() - +class FormBuilder(object): def __init__(self, fields): - for field in fields: - options = self.get_options(field) - f = getattr(self, "create_"+field.field_type+"_field")(field, options) - self.formfields[field.clean_name] = f - - def get_options(self, field): - options = {} - options['label'] = field.label - options['help_text'] = field.help_text - options['required'] = field.required - options['initial'] = field.default_value - return options + self.fields = fields def create_singleline_field(self, field, options): # TODO: This is a default value - it may need to be changed @@ -72,16 +59,52 @@ class FormBuilder(): def create_checkbox_field(self, field, options): return django.forms.BooleanField(**options) + FIELD_TYPES = { + 'singleline': create_singleline_field, + 'multiline': create_multiline_field, + 'date': create_date_field, + 'datetime': create_datetime_field, + 'email': create_email_field, + 'url': create_url_field, + 'number': create_number_field, + 'dropdown': create_dropdown_field, + 'radio': create_radio_field, + 'checkboxes': create_checkboxes_field, + 'checkbox': create_checkbox_field, + } + + @property + def formfields(self): + formfields = SortedDict() + + for field in self.fields: + options = self.get_field_options(field) + + if field.field_type in self.FIELD_TYPES: + formfields[field.clean_name] = self.FIELD_TYPES[field.field_type](self, field, options) + else: + raise Exception("Unrecognised field type: " + form.field_type) + + return formfields + + def get_field_options(self, field): + options = {} + options['label'] = field.label + options['help_text'] = field.help_text + options['required'] = field.required + options['initial'] = field.default_value + return options + def get_form_class(self): return type('WagtailForm', (BaseForm,), self.formfields) class SelectDateForm(django.forms.Form): - date_from = django.forms.DateField( + date_from = django.forms.DateTimeField( required=False, widget=django.forms.DateInput(attrs={'placeholder': 'Date from'}) ) - date_to = django.forms.DateField( + date_to = django.forms.DateTimeField( required=False, widget=django.forms.DateInput(attrs={'placeholder': 'Date to'}) ) diff --git a/wagtail/wagtailforms/migrations/0001_initial.py b/wagtail/wagtailforms/migrations/0001_initial.py index 80cbdb2b6..5543ff289 100644 --- a/wagtail/wagtailforms/migrations/0001_initial.py +++ b/wagtail/wagtailforms/migrations/0001_initial.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + from south.utils import datetime_utils as datetime from south.db import db from south.v2 import SchemaMigration @@ -15,32 +17,32 @@ class Migration(SchemaMigration): def forwards(self, orm): # Adding model 'FormSubmission' - db.create_table(u'wagtailforms_formsubmission', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + db.create_table('wagtailforms_formsubmission', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('form_data', self.gf('django.db.models.fields.TextField')()), ('page', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['wagtailcore.Page'])), ('submit_time', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), )) - db.send_create_signal(u'wagtailforms', ['FormSubmission']) + db.send_create_signal('wagtailforms', ['FormSubmission']) def backwards(self, orm): # Deleting model 'FormSubmission' - db.delete_table(u'wagtailforms_formsubmission') + db.delete_table('wagtailforms_formsubmission') models = { - u'auth.group': { + 'auth.group': { 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) }, AUTH_USER_MODEL: { @@ -48,33 +50,33 @@ class Migration(SchemaMigration): 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}), 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) }, - u'contenttypes.contenttype': { + 'contenttypes.contenttype': { 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, - u'wagtailcore.page': { + 'wagtailcore.page': { 'Meta': {'object_name': 'Page'}, - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"orm['contenttypes.ContentType']"}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['contenttypes.ContentType']"}), 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': u"orm['%s']" % AUTH_USER_MODEL}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': "orm['%s']" % AUTH_USER_MODEL}), 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), @@ -83,11 +85,11 @@ class Migration(SchemaMigration): 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) }, - u'wagtailforms.formsubmission': { + 'wagtailforms.formsubmission': { 'Meta': {'object_name': 'FormSubmission'}, 'form_data': ('django.db.models.fields.TextField', [], {}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'page': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['wagtailcore.Page']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['wagtailcore.Page']"}), 'submit_time': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}) } } diff --git a/wagtail/wagtailforms/models.py b/wagtail/wagtailforms/models.py index 75f0eceee..41414d391 100644 --- a/wagtail/wagtailforms/models.py +++ b/wagtail/wagtailforms/models.py @@ -1,11 +1,15 @@ +import json +import re + +from six import text_type + +from unidecode import unidecode + from django.db import models from django.shortcuts import render from django.utils.translation import ugettext_lazy as _ from django.utils.text import slugify - -from unidecode import unidecode -import json -import re +from django.utils.encoding import python_2_unicode_compatible from wagtail.wagtailcore.models import Page, Orderable, UserPagePermissionsProxy, get_page_types from wagtail.wagtailadmin.edit_handlers import FieldPanel @@ -32,6 +36,7 @@ FORM_FIELD_CHOICES = ( HTML_EXTENSION_RE = re.compile(r"(.*)\.html") +@python_2_unicode_compatible class FormSubmission(models.Model): """Data for a Form submission.""" @@ -43,7 +48,7 @@ class FormSubmission(models.Model): def get_data(self): return json.loads(self.form_data) - def __unicode__(self): + def __str__(self): return self.form_data @@ -73,7 +78,7 @@ class AbstractFormField(Orderable): # unidecode will return an ascii string while slugify wants a # unicode string on the other hand, slugify returns a safe-string # which will be converted to a normal str - return str(slugify(unicode(unidecode(self.label)))) + return str(slugify(text_type(unidecode(self.label)))) panels = [ FieldPanel('label'), diff --git a/wagtail/wagtailforms/templates/wagtailforms/index_submissions.html b/wagtail/wagtailforms/templates/wagtailforms/index_submissions.html index ae9572295..8e166111b 100644 --- a/wagtail/wagtailforms/templates/wagtailforms/index_submissions.html +++ b/wagtail/wagtailforms/templates/wagtailforms/index_submissions.html @@ -39,7 +39,7 @@