diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a0024b6a0..8b69d346a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -5,7 +5,9 @@ Changelog ~~~~~~~~~~~~~~~~ * Added toolbar to allow logged-in users to add and edit pages from the site front-end * Support for alternative image processing backends such as Wand, via the WAGTAILIMAGES_BACKENDS setting + * Added support for generating static sites using django-medusa * Added custom Query set for Pages with some handy methods for querying pages + * Added 'wagtailforms' module for creating form pages on a site, and handling form submissions * Editor's guide documentation * Editor interface now outputs form media CSS / JS, to support custom widgets with assets * Migrations and user management now correctly handle custom AUTH_USER_MODEL settings @@ -24,6 +26,8 @@ Changelog * Fix: Page slugs are now validated on page edit * Fix: Filter objects are cached to avoid a database hit every time an {% image %} tag is compiled * Fix: Moving or changing a site root page no longer causes URLs for subpages to change to 'None' + * Fix: Eliminated raw SQL queries from wagtailcore / wagtailadmin, to ensure cross-database compatibility + * Fix: Snippets menu item is hidden for administrators if no snippet types are defined 0.2 (11.03.2014) ~~~~~~~~~~~~~~~~ diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index 38e89a8e1..3549e8407 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -25,6 +25,7 @@ Contributors * Miguel Vieira * Ben Emery * David Smith +* Ben Margolis Translators =========== diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst new file mode 100644 index 000000000..7e23d8442 --- /dev/null +++ b/docs/advanced_topics.rst @@ -0,0 +1,17 @@ +Advanced Topics +~~~~~~~~~~~~~~~~ + +.. note:: + This documentation is currently being written. + +replacing image processing backend + +custom image processing tags? + +wagtail user bar custom CSS option? + +extending hallo editor plugins with editor_js() + +injecting any JS into page edit with editor_js() + +Custom content module (same level as docs or images) diff --git a/docs/building_your_site/djangodevelopers.rst b/docs/building_your_site/djangodevelopers.rst new file mode 100644 index 000000000..49f80c371 --- /dev/null +++ b/docs/building_your_site/djangodevelopers.rst @@ -0,0 +1,189 @@ +For Django developers +===================== + +.. note:: + This documentation is currently being written. + +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 contruction 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. + +The presentation of your content, the actual webpages, includes the normal use of the Django template system. We'll cover additional functionality that Wagtail provides at the template level later on. + +But first, we'll take a look at the ``Page`` class and model definitions. + + +The Page Class +~~~~~~~~~~~~~~ + +``Page`` uses Django's model interface, so you can include any field type and field options that Django allows. Wagtail provides some fields and editing handlers that simplify data entry in the Wagtail admin interface, so you may want to keep those in mind when deciding what properties to add to your models in addition to those already provided by ``Page``. + + +Built-in Properties of the Page Class +------------------------------------- + +Wagtail provides some properties in the ``Page`` class which are common to most webpages. Since you'll be subclassing ``Page``, you don't have to worry about implementing them. + +Public Properties +````````````````` + + ``title`` (string, required) + Human-readable title for the content + + ``slug`` (string, required) + Machine-readable URL component for this piece of content. The name of the page as it will appear in URLs e.g ``http://domain.com/blog/[my-slug]/`` + + ``seo_title`` (string) + Alternate SEO-crafted title which overrides the normal title for use in the ``
`` of a page + + ``search_description`` (string) + A SEO-crafted description of the content, used in both internal search indexing and for the meta description read by search engines + +The ``Page`` class actually has alot more to it, but these are probably the only built-in properties you'll need to worry about when creating templates for your models. + + +Anatomy of a Wagtail Model +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +So what does a Wagtail model definition look like? Here's a model representing a typical blog post: + +.. code-block:: python + + from django.db import models + + from wagtail.wagtailcore.models import Page + from wagtail.wagtailcore.fields import RichTextField + from wagtail.wagtailadmin.edit_handlers import FieldPanel + from wagtail.wagtailimages.edit_handlers import ImageChooserPanel + from wagtail.wagtailimages.models import Image + + class BlogPage(Page): + body = RichTextField() + date = models.DateField("Post date") + feed_image = models.ForeignKey( + 'wagtailimages.Image', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='+' + ) + + BlogPage.content_panels = [ + FieldPanel('title', classname="full title"), + FieldPanel('date'), + FieldPanel('body', classname="full"), + ] + + BlogPage.promote_panels = [ + FieldPanel('slug'), + FieldPanel('seo_title'), + FieldPanel('show_in_menus'), + FieldPanel('search_description'), + ImageChooserPanel('feed_image'), + ] + +To keep track of your ``Page``-derived models, it might be helpful to include "Page" as the last part of your classname. ``BlogPage`` defines three properties: ``body``, ``date``, and ``feed_image``. These are a mix of basic Django models (``DateField``), Wagtail fields (``RichTextField``), and a pointer to a Wagtail model (``Image``). + +Next, the ``content_panels`` and ``promote_panels`` lists define the capabilities and layout of the Wagtail admin page edit interface. The lists are filled with "panels" and "choosers", which will provide a fine-grain interface for inputting the model's content. The ``ImageChooserPanel``, for instance, lets one browse the image library, upload new images, and input image metadata. The ``RichTextField`` is the basic field for creating web-ready website rich text, including text formatting and embedded media like images and video. The Wagtail admin offers other choices for fields, Panels, and Choosers, with the option of creating your own to precisely fit your content without workarounds or other compromises. + +Your models may be even more complex, with methods overriding the built-in functionality of the ``Page`` to achieve webdev magic. Or, you can keep your models simple and let Wagtail's built-in functionality do the work. + +Now that we have a basic idea of how our content is defined, lets look at relationships between pieces of content. + + +Introduction to Trees +~~~~~~~~~~~~~~~~~~~~~ + +If you're unfamiliar with trees as an abstract data type, you might want to `review the concepts involved.{{ advert_placement.advert.text }}
+ {% endfor %} + {% endif %} + + diff --git a/docs/static_site_generation.rst b/docs/static_site_generation.rst new file mode 100644 index 000000000..a9379cd67 --- /dev/null +++ b/docs/static_site_generation.rst @@ -0,0 +1,83 @@ +Generating a static site +======================== + +This document describes how to render your Wagtail site into static HTML files on your local filesystem, Amazon S3 or Google App Engine, using `django medusa`_ and the ``wagtail.contrib.wagtailmedusa`` module. + + +Installing django-medusa +~~~~~~~~~~~~~~~~~~~~~~~~ + +First, install django medusa from pip: + +.. code:: + + pip install django-medusa + + +Then add ``django_medusa`` and ``wagtail.contrib.wagtailmedusa`` to ``INSTALLED_APPS``: + +.. code:: python + + INSTALLED_APPS = [ + ... + 'django_medusa', + 'wagtail.contrib.wagtailmedusa', + ] + + +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. + +For example, let's say we have a Blog Index which uses pagination. We can override the ``route`` method to make it respond on urls like '/page/1', and pass the page number through to the ``serve`` method: + +.. code:: python + + class BlogIndex(Page): + ... + + def serve(self, request, page=1): + ... + + def route(self, request, path_components): + if self.live and len(path_components) == 2 and path_components[0] == 'page': + try: + return self.serve(request, page=int(path_components[1])) + except (TypeError, ValueError): + pass + + return super(BlogIndex, self).route(request, path_components) + + +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. + +For example, the BlogIndex above would need to yield one URL for each page of results: + +.. code:: python + + def get_static_site_paths(self): + # Get page count + page_count = ... + + # Yield a path for each page + for page in range(page_count): + yield '/%d/' % (page + 1) + + # Yield from superclass + for path in super(BlogIndex, self).get_static_site_paths(): + 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{{ editors_pick.description|safe }}
+Thank you for your feedback.
+ + diff --git a/wagtail/tests/utils.py b/wagtail/tests/utils.py index 11cdb369d..7b6557a78 100644 --- a/wagtail/tests/utils.py +++ b/wagtail/tests/utils.py @@ -1,9 +1,20 @@ from django.contrib.auth.models import User +# We need to make sure that we're using the same unittest library that Django uses internally +# Otherwise, we get issues with the "SkipTest" and "ExpectedFailure" exceptions being recognised as errors +try: + # Firstly, try to import unittest from Django + from django.utils import unittest +except ImportError: + # Django doesn't include unittest + # We must be running on Django 1.7+ which doesn't support Python 2.6 so + # the standard unittest library should be unittest2 + import unittest + def login(client): # Create a user User.objects.create_superuser(username='test', email='test@email.com', password='password') # Login - client.login(username='test', password='password') \ No newline at end of file + client.login(username='test', password='password') diff --git a/wagtail/wagtailadmin/static/wagtailadmin/js/core.js b/wagtail/wagtailadmin/static/wagtailadmin/js/core.js index e5895e34f..380c3d179 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/js/core.js +++ b/wagtail/wagtailadmin/static/wagtailadmin/js/core.js @@ -128,7 +128,7 @@ $(function(){ $(window.headerSearch.termInput).trigger('focus'); function search () { - var workingClasses = "working icon icon-spinner"; + var workingClasses = "icon-spinner"; $(window.headerSearch.termInput).parent().addClass(workingClasses); search_next_index++; diff --git a/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js b/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js index 6c58974a2..2dd97125f 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js +++ b/wagtail/wagtailadmin/static/wagtailadmin/js/page-editor.js @@ -1,3 +1,15 @@ +var halloPlugins = { + 'halloformat': {}, + 'halloheadings': {formatBlocks: ["p", "h2", "h3", "h4", "h5"]}, + 'hallolists': {}, + 'hallohr': {}, + 'halloreundo': {}, + 'hallowagtaillink': {}, +}; +function registerHalloPlugin(name, opts) { + halloPlugins[name] = (opts || {}); +} + function makeRichTextEditable(id) { var input = $('#' + id); var richText = $('').html(input.val()); @@ -19,17 +31,7 @@ function makeRichTextEditable(id) { richText.hallo({ toolbar: 'halloToolbarFixed', toolbarcssClass: 'testy', - plugins: { - 'halloformat': {}, - 'halloheadings': {formatBlocks: ["p", "h2", "h3", "h4", "h5"]}, - 'hallolists': {}, - 'hallohr': {}, - 'halloreundo': {}, - 'hallowagtailimage': {}, - 'hallowagtailembeds': {}, - 'hallowagtaillink': {}, - 'hallowagtaildoclink': {} - } + plugins: halloPlugins }).bind('hallomodified', function(event, data) { input.val(data.content); if (!removeStylingPending) { diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss index 150611f8f..97e915f36 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/formatters.scss @@ -181,4 +181,9 @@ a.tag:hover{ .unlist{ @include unlist(); +} + +/* utility class to allow things to be scrollable if their contents can't wrap more nicely */ +.overflow{ + overflow:auto; } \ No newline at end of file diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss index 9ef27297c..29c44b4f3 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/forms.scss @@ -21,22 +21,13 @@ legend{ @include visuallyhidden(); } -.fields li{ - padding-top:0.5em; - padding-bottom:0.5em; -} - -.field{ - padding:0 0 0.6em 0; -} - label{ font-weight:bold; color:$color-grey-1; font-size:1.1em; display:block; padding:0 0 0.8em 0; - line-height:1em; + line-height:1.3em; .checkbox &, .radio &{ @@ -47,10 +38,10 @@ label{ input, textarea, select, .richtext, .tagit{ @include border-radius(6px); @include border-box(); - font-family:Open Sans,Arial,sans-serif; width:100%; - border:1px dashed $color-input-border; - padding:1.2em; + font-family:Open Sans,Arial,sans-serif; + border:1px solid $color-input-border; + padding:0.9em 1.2em; background-color:$color-fieldset-hover; -webkit-appearance: none; color:$color-text-input; @@ -76,10 +67,44 @@ input, textarea, select, .richtext, .tagit{ } } -input[type=radio],input[type=checkbox]{ +/* select boxes */ +.typed_choice_field .input{ + position:relative; + + select{ + outline:none; + } + + &:after{ + @include border-radius(0 6px 6px 0); + z-index:0; + position:absolute; + right:1px; + top:1px; + height:95%; + width:1.5em; + font-family:wagtail; + content:"q"; + border:1px solid $color-input-border; + border-width:0 0 0 1px; + text-align:center; + line-height:1.4em; + font-size:3em; + pointer-events:none; + color:$color-grey-3; + background-color:$color-fieldset-hover; + margin:0px 1px 0 0; + } + + .ie &:after{ + display:none; + } +} + +/* radio and check boxes */ +input[type=radio], input[type=checkbox]{ @include border-radius(0); cursor:pointer; - float:left; border:0; } @@ -350,18 +375,15 @@ button.icon{ .help, .error-message{ font-size:0.85em; font-weight:normal; - margin:0 0 0.5em 0; + margin:0.5em 0 0 0; +} +.error-message{ + color:$color-red; } .help{ color:$color-grey-2; } -/* permanently show checkbox/radio help as they have no focus state */ -.boolean_field .help, .radio .help{ - opacity:1; -} - - fieldset:hover > .help, .field.focused + .help, .field:focus + .help, @@ -380,18 +402,77 @@ li.focused > .help{ font-size:13px; } -.error-message{ - margin:0; - color:$color-red; - clear:both; -} - .error input, .error textarea, .error select, .error .tagit{ border-color:$color-red; background-color:$color-input-error-bg; } +/* Layouts for particular kinds of of fields */ + +/* permanently show checkbox/radio help as they have no focus state */ +.boolean_field .help, .radio .help{ + opacity:1; +} +.iconfield { + position:relative; + + input:not([type=radio]), input:not([type=checkbox]), input:not([type=submit]), input:not([type=button]){ + padding-left:2.5em; + } + + &:before, &:after{ + font-family:wagtail; + position:absolute; + top:0.4em; + font-size:1.4em; + color:$color-grey-3; + } + &:before{ + left:0.5em; + } + &:after{ + right:0.5em; + } + + /* special case for search spinners */ + &.icon-spinner:after{ + color:$color-teal; + opacity:0.8; + font-size:20px; + width:20px; + height:20px; + line-height:23px; + text-align:center; + top:0.3em; + + } +} + +.fields li{ + padding-top:0.5em; + padding-bottom:1.2em; +} + +.field-content .input li{ + label{ + width:auto; + float:none; + } +} + +.input{ + clear:both; +} + /* field sizing */ + +.field-small{ + input, textarea, select, .richtext, .tagit{ + @include border-radius(3px); + padding:0.4em 1em; + } +} + .field{ &.col1, &.col2, @@ -453,7 +534,7 @@ ul.inline li:first-child, li.inline:first-child{ display:block; float:left; color:$color-grey-3; - line-height:0.85em; + line-height:1em; font-size:2.5em; margin-right:0.3em; } @@ -495,7 +576,6 @@ ul.inline li:first-child, li.inline:first-child{ .unchosen, .chosen{ &:before{ content:"b"; - margin-left:-0.1em; /* this glyphs appear to have left padding, counteracted here */ } } } @@ -568,112 +648,6 @@ ul.tagit li.tagit-choice-editable{ } } - -/* search bars (search integrated into header area) */ -.search-bar{ - margin-top:-2em; - padding-top:1em; - padding-bottom:1em; - margin-bottom:2em; - - &.full-width{ - @include nice-padding(); - background-color:$color-header-bg; - border-bottom:1px solid $color-grey-4; - } - - label{ - display:none; - } - - .fields{ - position:relative; - clear:both; - - .field input{ - padding-left:3em; - - &:focus{ - background-color:white; - } - } - .field:before, .field:after{ - font-family:wagtail; - position:absolute; - top:1em; - font-size:25px; - - } - .field:before{ - left:0.5em; - content:"f"; - color:$color-grey-3; - } - .field:after{ - color:$color-teal; - opacity:0.8; - font-size:20px; - width:20px; - height:20px; - line-height:23px; - text-align:center; - top:0.3em; - right:0.5em; - } - } - .submit{ - display:none; - position:absolute; - right:0; - top:0; - input{ - padding:1.55em 2em; - } - } - .taglist{ - font-size:0.9em; - line-height:2.4em; - h3{ - display:inline; - } - a{ - white-space: nowrap - } - } - - &.small{ - margin:0; - padding:0; - .fields{ - li{ - padding:0; - } - .field{ - padding:0; - } - .field input{ - padding:0.4em 1.4em 0.4em 2em; - - &:focus{ - background-color:white; - } - } - .field:before{ - font-size:1.1rem; - top:0.45em; - } - - } - } -} - -/* mozilla specific hack */ -@-moz-document url-prefix() { - .search-bar .fields .field:after{ - line-height:20px; - } -} - /* Transitions */ fieldset, input, textarea, select{ @include transition(background-color 0.2s ease); @@ -686,11 +660,22 @@ input[type=submit], input[type=reset], input[type=button], .button, button{ } @media screen and (min-width: $breakpoint-mobile){ - .help{ - opacity:1; - } - .fields{ - max-width:800px; + label{ + @include column(2); + padding-top:1.2em; + padding-left:0; + + .model_multiple_choice_field &, + .boolean_field &, + .model_choice_field &, + .image_field &, + .file_field &{ + padding-top:0; + } + + .boolean_field &{ + padding-bottom:0; + } } input[type=submit], input[type=reset], input[type=button], .button, button{ @@ -706,4 +691,20 @@ input[type=submit], input[type=reset], input[type=button], .button, button{ } } } + + .help{ + opacity:1; + } + .fields{ + max-width:800px; + } + + .field{ + @include row(); + } + + .field-content{ + @include column(10); + padding-right:0; + } } \ No newline at end of file diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/header.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/header.scss new file mode 100644 index 000000000..849420ae0 --- /dev/null +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/header.scss @@ -0,0 +1,156 @@ +header{ + padding-top:1em; + padding-bottom:1em; + background-color: $color-header-bg; + margin-bottom:2em; + color:white; + + h1, h2{ + margin:0; + color:white; + } + + h1{ + padding:0.2em 0; + + &.icon:before{ + width:1em; + display:none; + margin-right:0.4em; + font-size:1.5em; + } + } + + .col{ + float:left; + margin-right:2em; + } + .left{ + float:left; + + .hasform &:first-child{ + padding-bottom:0.5em; + float:none; + } + } + .right{ + text-align:right; + float:right; + } + + /* For case where content below header should merge with it */ + &.merged{ + margin-bottom:0; + } + &.tab-merged, &.no-border{ + border:0; + } + &.merged.no-border{ + padding-bottom:0; + } + &.no-v-padding{ + padding-top:0; + padding-bottom:0; + } + /* + &.hasform h1{ + margin-top:0.2em; + } + */ + .button{ + background-color:$color-teal-darker; + &:hover{ + background-color:$color-teal-dark; + } + } + + /* necessary on mobile only to make way for hamburger menu */ + &.nice-padding{ + padding-left:4em; + } + + label{ + @include visuallyhidden(); + } + + input[type=text], select{ + border-width:0; + + &:focus{ + background-color:white; + } + } + + .fields{ + margin-top:-0.5em; + li{ + padding-bottom:0; + } + .field{ + padding:0; + } + } + + .field-content{ + width:auto; + padding:0; + } +} + +/* mozilla specific hack */ +@-moz-document url-prefix() { + .iconfield.icon-spinner:after{ + line-height:20px; + } +} + +.page-explorer header{ + margin-bottom:0; + padding-bottom:0em; +} + + +@media screen and (min-width: $breakpoint-mobile){ + header{ + padding-top:1.5em; + padding-bottom:1.5em; + + .left{ + float:left; + margin-right:0; + + &:first-child{ + padding-bottom:0; + float:left; + } + } + .second{ + clear:none; + + .right, .left{ + float:right; + } + } + + h1.icon:before{ + display:inline-block; + } + + .col3{ + @include column(3); + } + .col3.addbutton{ + width:auto; + } + .col6{ + @include column(6); + } + .col9{ + @include column(9); + } + .breadcrumb{ + margin-left:-($desktop-nice-padding); + margin-right:-($desktop-nice-padding); + } + } +} \ No newline at end of file diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/icons.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/icons.scss index a1aa3cca0..7fb3320dd 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/components/icons.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/components/icons.scss @@ -231,14 +231,20 @@ .icon-collapse-up:before{ content:"6"; } +.icon-date:before{ + content:"7"; +} +.icon-success:before{ + content:"9"; +} .icon-help:before{ content:"?"; } .icon-warning:before{ content:"!"; } -.icon-success:before{ - content:"9"; +.icon-form:before{ + content:"$"; } .icon-date:before{ content:"7"; diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/core.scss b/wagtail/wagtailadmin/static/wagtailadmin/scss/core.scss index 2d77d7628..80beb7f5f 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/core.scss +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/core.scss @@ -11,6 +11,7 @@ @import "components/listing.scss"; @import "components/messages.scss"; @import "components/formatters.scss"; +@import "components/header.scss"; @import "fonts.scss"; @@ -115,7 +116,7 @@ img{ } .nav-wrapper{ - @include box-shadow(inset -2px 0px 10px 0px rgba(0, 0, 0, 0.5)); + @include box-shadow(inset -5px 0px 5px -3px rgba(0, 0, 0, 0.3)); position:relative; background: $color-grey-1; margin-left: -100%; @@ -385,88 +386,6 @@ body.explorer-open { } } -header{ - padding-top:1em; - padding-bottom:1em; - background-color: $color-header-bg; - margin-bottom:2em; - color:white; - - h1, h2{ - margin:0; - color:white; - } - - h1{ - padding:0.2em 0; - - &.icon:before{ - width:1em; - display:none; - margin-right:0.4em; - font-size:1.5em; - } - } - - .col{ - float:left; - margin-right:2em; - } - .left{ - float:left; - - .hasform &:first-child{ - padding-bottom:0.5em; - float:none; - } - } - .search-bar input{ - @include border-radius(3px); - width:auto; - border-width:0; - } - .right{ - text-align:right; - float:right; - } - - /* For case where content below header should merge with it */ - &.merged{ - margin-bottom:0; - } - &.tab-merged, &.no-border{ - border:0; - } - &.merged.no-border{ - padding-bottom:0; - } - &.no-v-padding{ - padding-top:0; - padding-bottom:0; - } - /* - &.hasform h1{ - margin-top:0.2em; - } - */ - .button{ - background-color:$color-teal-darker; - &:hover{ - background-color:$color-teal-dark; - } - } - /* necessary on mobile only to make way for hamburger menu */ - &.nice-padding{ - padding-left:4em; - } -} - -.page-explorer header{ - margin-bottom:0; - padding-bottom:0em; -} - - footer{ @include row(); @include border-radius(3px 3px 0 0); @@ -832,48 +751,6 @@ footer, .logo{ } } - header{ - padding-top:1.5em; - padding-bottom:1.5em; - - .left{ - float:left; - margin-right:0; - - &:first-child{ - padding-bottom:0; - float:left; - } - } - .second{ - clear:none; - - .right, .left{ - float:right; - } - } - - h1.icon:before{ - display:inline-block; - } - - .col3{ - @include column(3); - } - .col3.addbutton{ - width:auto; - } - .col6{ - @include column(6); - } - .col9{ - @include column(9); - } - .breadcrumb{ - margin-left:-($desktop-nice-padding); - margin-right:-($desktop-nice-padding); - } - } footer{ width:80%; margin-left:50px; @@ -900,13 +777,8 @@ footer, .logo{ .wrapper{ max-width:$breakpoint-desktop-larger; } - .nav-wrapper{ - @include box-shadow(inset -6px 0px 4px 0px rgba(0, 0, 0, 0.2)); - - .inner{ - background:$color-grey-1; - @include box-shadow(inset -6px 0px 4px 0px rgba(0, 0, 0, 0.2)); - } + .nav-wrapper .inner{ + background:$color-grey-1; } footer{ diff --git a/wagtail/wagtailadmin/static/wagtailadmin/scss/fonts/wagtail.svg b/wagtail/wagtailadmin/static/wagtailadmin/scss/fonts/wagtail.svg index dcfa5a08c..f25359ec6 100755 --- a/wagtail/wagtailadmin/static/wagtailadmin/scss/fonts/wagtail.svg +++ b/wagtail/wagtailadmin/static/wagtailadmin/scss/fonts/wagtail.svg @@ -6,6 +6,7 @@{{ field.help_text }}
+ {% endif %} + + {% if field.errors %} + + {% endif %} +{{ field.help_text }}
-{% endif %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/login.html b/wagtail/wagtailadmin/templates/wagtailadmin/login.html index 9cb28ed86..012dd32f8 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/login.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/login.html @@ -28,15 +28,19 @@Blah blah blah
", + } + def test_get_embed(self): embed = get_embed('www.test.com/1234', max_width=400, finder=self.dummy_finder) @@ -31,20 +45,23 @@ class TestEmbeds(TestCase): embed = get_embed('www.test.com/4321', finder=self.dummy_finder) self.assertEqual(self.hit_count, 3) - def dummy_finder(self, url, max_width=None): - # Up hit count - self.hit_count += 1 - - # Return a pretend record + def dummy_finder_invalid_width(self, url, max_width=None): + # Return a record with an invalid width return { 'title': "Test: " + url, 'type': 'video', 'thumbnail_url': '', - 'width': max_width if max_width else 640, + 'width': '100%', 'height': 480, 'html': "Blah blah blah
", } + def test_invalid_width(self): + embed = get_embed('www.test.com/1234', max_width=400, finder=self.dummy_finder_invalid_width) + + # Width must be set to None + self.assertEqual(embed.width, None) + class TestChooser(TestCase): def setUp(self): diff --git a/wagtail/wagtailembeds/wagtail_hooks.py b/wagtail/wagtailembeds/wagtail_hooks.py index 0db627881..89c5e66a5 100644 --- a/wagtail/wagtailembeds/wagtail_hooks.py +++ b/wagtail/wagtailembeds/wagtail_hooks.py @@ -1,4 +1,7 @@ +from django.conf import settings 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.wagtailembeds import urls @@ -9,3 +12,18 @@ def register_admin_urls(): url(r'^embeds/', include(urls)), ] hooks.register('register_admin_urls', register_admin_urls) + + +def editor_js(): + return format_html(""" + + + """, + settings.STATIC_URL, + 'wagtailembeds/js/hallo-plugins/hallo-wagtailembeds.js', + urlresolvers.reverse('wagtailembeds_chooser') + ) +hooks.register('insert_editor_js', editor_js) diff --git a/wagtail/wagtailforms/__init__.py b/wagtail/wagtailforms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/wagtailforms/forms.py b/wagtail/wagtailforms/forms.py new file mode 100644 index 000000000..f17023a77 --- /dev/null +++ b/wagtail/wagtailforms/forms.py @@ -0,0 +1,87 @@ +import django.forms +from django.utils.datastructures import SortedDict + + +class BaseForm(django.forms.Form): + def __init__(self, *args, **kwargs): + kwargs.setdefault('label_suffix', '') + return super(BaseForm, self).__init__(*args, **kwargs) + + +class FormBuilder(): + formfields = SortedDict() + + 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 + + def create_singleline_field(self, field, options): + # TODO: This is a default value - it may need to be changed + options['max_length'] = 255 + return django.forms.CharField(**options) + + def create_multiline_field(self, field, options): + return django.forms.CharField(widget=django.forms.Textarea, **options) + + def create_date_field(self, field, options): + return django.forms.DateField(**options) + + def create_datetime_field(self, field, options): + return django.forms.DateTimeField(**options) + + def create_email_field(self, field, options): + return django.forms.EmailField(**options) + + def create_url_field(self, field, options): + return django.forms.URLField(**options) + + def create_number_field(self, field, options): + return django.forms.DecimalField(**options) + + def create_dropdown_field(self, field, options): + options['choices'] = map( + lambda x: (x.strip(), x.strip()), + field.choices.split(',') + ) + return django.forms.ChoiceField(**options) + + def create_radio_field(self, field, options): + options['choices'] = map( + lambda x: (x.strip(), x.strip()), + field.choices.split(',') + ) + return django.forms.ChoiceField(widget=django.forms.RadioSelect, **options) + + def create_checkboxes_field(self, field, options): + options['choices'] = [(x.strip(), x.strip()) for x in field.choices.split(',')] + options['initial'] = [x.strip() for x in field.default_value.split(',')] + return django.forms.MultipleChoiceField( + widget=django.forms.CheckboxSelectMultiple, **options + ) + + def create_checkbox_field(self, field, options): + return django.forms.BooleanField(**options) + + def get_form_class(self): + return type('WagtailForm', (BaseForm,), self.formfields) + + +class SelectDateForm(django.forms.Form): + date_from = django.forms.DateField( + required=False, + widget=django.forms.DateInput(attrs={'placeholder': 'Date from'}) + ) + date_to = django.forms.DateField( + 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 new file mode 100644 index 000000000..80cbdb2b6 --- /dev/null +++ b/wagtail/wagtailforms/migrations/0001_initial.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +from wagtail.wagtailcore.compat import AUTH_USER_MODEL, AUTH_USER_MODEL_NAME + + +class Migration(SchemaMigration): + + depends_on = ( + ("wagtailcore", "0002_initial_data"), + ) + + 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)), + ('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']) + + + def backwards(self, orm): + # Deleting model 'FormSubmission' + db.delete_table(u'wagtailforms_formsubmission') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'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'}) + }, + 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'}, + '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'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + AUTH_USER_MODEL: { + 'Meta': {'object_name': AUTH_USER_MODEL_NAME}, + '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'}), + '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']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'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'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'wagtailcore.page': { + 'Meta': {'object_name': 'Page'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"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'}), + '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}), + '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'}) + }, + u'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']"}), + 'submit_time': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['wagtailforms'] \ No newline at end of file diff --git a/wagtail/wagtailforms/migrations/__init__.py b/wagtail/wagtailforms/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/wagtailforms/models.py b/wagtail/wagtailforms/models.py new file mode 100644 index 000000000..75f0eceee --- /dev/null +++ b/wagtail/wagtailforms/models.py @@ -0,0 +1,200 @@ +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 wagtail.wagtailcore.models import Page, Orderable, UserPagePermissionsProxy, get_page_types +from wagtail.wagtailadmin.edit_handlers import FieldPanel +from wagtail.wagtailadmin import tasks + +from .forms import FormBuilder + + +FORM_FIELD_CHOICES = ( + ('singleline', _('Single line text')), + ('multiline', _('Multi-line text')), + ('email', _('Email')), + ('number', _('Number')), + ('url', _('URL')), + ('checkbox', _('Checkbox')), + ('checkboxes', _('Checkboxes')), + ('dropdown', _('Drop down')), + ('radio', _('Radio buttons')), + ('date', _('Date')), + ('datetime', _('Date/time')), +) + + +HTML_EXTENSION_RE = re.compile(r"(.*)\.html") + + +class FormSubmission(models.Model): + """Data for a Form submission.""" + + form_data = models.TextField() + page = models.ForeignKey(Page) + + submit_time = models.DateTimeField(auto_now_add=True) + + def get_data(self): + return json.loads(self.form_data) + + def __unicode__(self): + return self.form_data + + +class AbstractFormField(Orderable): + """Database Fields required for building a Django Form field.""" + + label = models.CharField( + max_length=255, + help_text=_('The label of the form field') + ) + field_type = models.CharField(max_length=16, choices=FORM_FIELD_CHOICES) + required = models.BooleanField(default=True) + choices = models.CharField( + max_length=512, + blank=True, + help_text=_('Comma seperated list of choices. Only applicable in checkboxes, radio and dropdown.') + ) + default_value = models.CharField( + max_length=255, + blank=True, + help_text=_('Default value. Comma seperated values supported for checkboxes.') + ) + help_text = models.CharField(max_length=255, blank=True) + + @property + def clean_name(self): + # 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)))) + + panels = [ + FieldPanel('label'), + FieldPanel('help_text'), + FieldPanel('required'), + FieldPanel('field_type', classname="formbuilder-type"), + FieldPanel('choices', classname="formbuilder-choices"), + FieldPanel('default_value', classname="formbuilder-default"), + ] + + class Meta: + abstract = True + ordering = ['sort_order'] + + +_FORM_CONTENT_TYPES = None + +def get_form_types(): + global _FORM_CONTENT_TYPES + if _FORM_CONTENT_TYPES is None: + _FORM_CONTENT_TYPES = [ + ct for ct in get_page_types() + if issubclass(ct.model_class(), AbstractForm) + ] + return _FORM_CONTENT_TYPES + + +def get_forms_for_user(user): + """Return a queryset of form pages that this user is allowed to access the submissions for""" + editable_pages = UserPagePermissionsProxy(user).editable_pages() + return editable_pages.filter(content_type__in=get_form_types()) + + +class AbstractForm(Page): + """A Form Page. Pages implementing a form should inhert from it""" + + form_builder = FormBuilder + is_abstract = True # Don't display me in "Add" + + def __init__(self, *args, **kwargs): + super(AbstractForm, self).__init__(*args, **kwargs) + if not hasattr(self, 'landing_page_template'): + template_wo_ext = re.match(HTML_EXTENSION_RE, self.template).group(1) + self.landing_page_template = template_wo_ext + '_landing.html' + + class Meta: + abstract = True + + def get_form_parameters(self): + return {} + + def process_form_submission(self, form): + # remove csrf_token from form.data + form_data = dict( + i for i in form.data.items() + if i[0] != 'csrfmiddlewaretoken' + ) + + FormSubmission.objects.create( + form_data=json.dumps(form_data), + page=self, + ) + + def serve(self, request): + fb = self.form_builder(self.form_fields.all()) + form_class = fb.get_form_class() + form_params = self.get_form_parameters() + + if request.method == 'POST': + form = form_class(request.POST, **form_params) + + if form.is_valid(): + self.process_form_submission(form) + # If we have a form_processing_backend call its process method + if hasattr(self, 'form_processing_backend'): + form_processor = self.form_processing_backend() + form_processor.process(self, form) + + # render the landing_page + # TODO: It is much better to redirect to it + return render(request, self.landing_page_template, { + 'self': self, + }) + else: + form = form_class(**form_params) + + return render(request, self.template, { + 'self': self, + 'form': form, + }) + + def get_page_modes(self): + return [ + ('form', 'Form'), + ('landing', 'Landing page'), + ] + + def show_as_mode(self, mode): + if mode == 'landing': + return render(self.dummy_request(), self.landing_page_template, { + 'self': self, + }) + else: + return super(AbstractForm, self).show_as_mode(mode) + + +class AbstractEmailForm(AbstractForm): + """A Form Page that sends email. Pages implementing a form to be send to an email should inherit from it""" + is_abstract = True # Don't display me in "Add" + + to_address = models.CharField(max_length=255, blank=True, help_text=_("Optional - form submissions will be emailed to this address")) + from_address = models.CharField(max_length=255, blank=True) + subject = models.CharField(max_length=255, blank=True) + + def process_form_submission(self, form): + super(AbstractEmailForm, self).process_form_submission(form) + + if self.to_address: + content = '\n'.join([x[1].label + ': ' + form.data.get(x[0]) for x in form.fields.items()]) + tasks.send_email_task.delay(self.subject, content, [self.to_address], self.from_address,) + + + class Meta: + abstract = True diff --git a/wagtail/wagtailforms/static/wagtailforms/js/page-editor.js b/wagtail/wagtailforms/static/wagtailforms/js/page-editor.js new file mode 100644 index 000000000..8e7398133 --- /dev/null +++ b/wagtail/wagtailforms/static/wagtailforms/js/page-editor.js @@ -0,0 +1,3 @@ +$(function(){ + +}); \ No newline at end of file diff --git a/wagtail/wagtailforms/templates/wagtailforms/index.html b/wagtail/wagtailforms/templates/wagtailforms/index.html new file mode 100644 index 000000000..c5ccc1aa4 --- /dev/null +++ b/wagtail/wagtailforms/templates/wagtailforms/index.html @@ -0,0 +1,15 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n %} +{% block titletag %}{% trans "Forms" %}{% endblock %} +{% block bodyclass %}menu-forms{% endblock %} +{% block content %} + {% trans "Forms" as forms_str %} + {% trans "Pages" as select_form_str %} + {% include "wagtailadmin/shared/header.html" with title=forms_str subtitle=select_form_str icon="form" %} + +| {% trans "Title" %} | +{% trans "Origin" %} | +
|---|---|
+ {{ fp|capfirst }}+ |
+ + {{ fp.content_type.name |capfirst }} ({{ fp.content_type.app_label }}.{{ fp.content_type.model }}) + | +
| {% trans "Submission Date" %} | + {% for heading in data_headings %} +{{ heading }} | + {% endfor %} +
|---|---|
| + {{ cell }} + | + {% endfor %} +
{% trans "No form pages have been created." %}
+{% endif %} diff --git a/wagtail/wagtailforms/tests.py b/wagtail/wagtailforms/tests.py new file mode 100644 index 000000000..8ec2ee5c3 --- /dev/null +++ b/wagtail/wagtailforms/tests.py @@ -0,0 +1,63 @@ +from django.test import TestCase + +from wagtail.wagtailcore.models import Page +from wagtail.wagtailforms.models import FormSubmission + +class TestFormSubmission(TestCase): + fixtures = ['test.json'] + + def test_get_form(self): + response = self.client.get('/contact-us/') + self.assertContains(response, """""") + self.assertNotContains(response, "Thank you for your feedback") + + def test_post_invalid_form(self): + response = self.client.post('/contact-us/', { + 'your-email': 'bob', 'your-message': 'hello world' + }) + self.assertNotContains(response, "Thank you for your feedback") + self.assertContains(response, "Enter a valid email address.") + + def test_post_valid_form(self): + response = self.client.post('/contact-us/', { + 'your-email': 'bob@example.com', 'your-message': 'hello world' + }) + self.assertNotContains(response, "Your email") + self.assertContains(response, "Thank you for your feedback") + + form_page = Page.objects.get(url_path='/home/contact-us/') + + self.assertTrue(FormSubmission.objects.filter(page=form_page, form_data__contains='hello world').exists()) + + +class TestFormsBackend(TestCase): + fixtures = ['test.json'] + + def test_cannot_see_forms_without_permission(self): + form_page = Page.objects.get(url_path='/home/contact-us/') + + self.client.login(username='eventeditor', password='password') + response = self.client.get('/admin/forms/') + self.assertFalse(form_page in response.context['form_pages']) + + def test_can_see_forms_with_permission(self): + form_page = Page.objects.get(url_path='/home/contact-us/') + + self.client.login(username='siteeditor', password='password') + response = self.client.get('/admin/forms/') + self.assertTrue(form_page in response.context['form_pages']) + + def test_can_get_submissions(self): + form_page = Page.objects.get(url_path='/home/contact-us/') + + self.client.login(username='siteeditor', password='password') + + response = self.client.get('/admin/forms/submissions/%d/' % form_page.id) + self.assertEqual(len(response.context['data_rows']), 2) + + response = self.client.get('/admin/forms/submissions/%d/?date_from=01%%2F01%%2F2014' % form_page.id) + self.assertEqual(len(response.context['data_rows']), 1) + + response = self.client.get('/admin/forms/submissions/%d/?date_from=01%%2F01%%2F2014&action=CSV' % form_page.id) + data_line = response.content.split("\n")[1] + self.assertTrue('new@example.com' in data_line) diff --git a/wagtail/wagtailforms/urls.py b/wagtail/wagtailforms/urls.py new file mode 100644 index 000000000..e3f6bf6a8 --- /dev/null +++ b/wagtail/wagtailforms/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, url + + +urlpatterns = patterns( + 'wagtail.wagtailforms.views', + url(r'^$', 'index', name='wagtailforms_index'), + url(r'^submissions/(\d+)/$', 'list_submissions', name='wagtailforms_list_submissions'), + +) diff --git a/wagtail/wagtailforms/views.py b/wagtail/wagtailforms/views.py new file mode 100644 index 000000000..2174642c6 --- /dev/null +++ b/wagtail/wagtailforms/views.py @@ -0,0 +1,104 @@ +import datetime +import unicodecsv + +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, render +from django.contrib.auth.decorators import permission_required + +from wagtail.wagtailcore.models import Page +from wagtail.wagtailforms.models import FormSubmission, get_forms_for_user +from wagtail.wagtailforms.forms import SelectDateForm + + +@permission_required('wagtailadmin.access_admin') +def index(request): + p = request.GET.get("p", 1) + + form_pages = get_forms_for_user(request.user) + + paginator = Paginator(form_pages, 20) + + try: + form_pages = paginator.page(p) + except PageNotAnInteger: + form_pages = paginator.page(1) + except EmptyPage: + form_pages = paginator.page(paginator.num_pages) + + return render(request, 'wagtailforms/index.html', { + 'form_pages': form_pages, + }) + + +@permission_required('wagtailadmin.access_admin') +def list_submissions(request, page_id): + form_page = get_object_or_404(Page, id=page_id).specific + + if not get_forms_for_user(request.user).filter(id=page_id).exists(): + raise PermissionDenied + + data_fields = [ + (field.clean_name, field.label) + for field in form_page.form_fields.all() + ] + + submissions = FormSubmission.objects.filter(page=form_page) + + select_date_form = SelectDateForm(request.GET) + if select_date_form.is_valid(): + date_from = select_date_form.cleaned_data.get('date_from') + date_to = select_date_form.cleaned_data.get('date_to') + # careful: date_to should be increased by 1 day since the submit_time + # is a time so it will always be greater + if date_to: + date_to += datetime.timedelta(days=1) + if date_from and date_to: + submissions = submissions.filter(submit_time__range=[date_from, date_to]) + elif date_from and not date_to: + submissions = submissions.filter(submit_time__gte=date_from) + elif not date_from and date_to: + submissions = submissions.filter(submit_time__lte=date_to) + + if request.GET.get('action') == 'CSV': + # return a CSV instead + response = HttpResponse(content_type='text/csv; charset=utf-8') + response['Content-Disposition'] = 'attachment;filename=export.csv' + writer = unicodecsv.writer(response, encoding='utf-8') + + header_row = ['Submission date'] + [label for name, label in data_fields] + + writer.writerow(header_row) + for s in submissions: + data_row = [s.submit_time] + form_data = s.get_data() + for name, label in data_fields: + data_row.append(form_data.get(name)) + writer.writerow(data_row) + return response + + p = request.GET.get('p', 1) + paginator = Paginator(submissions, 20) + + try: + submissions = paginator.page(p) + except PageNotAnInteger: + submissions = paginator.page(1) + except EmptyPage: + submissions = paginator.page(paginator.num_pages) + + data_headings = [label for name, label in data_fields] + data_rows = [] + for s in submissions: + form_data = s.get_data() + data_row = [s.submit_time] + [form_data.get(name) for name, label in data_fields] + data_rows.append(data_row) + + return render(request, 'wagtailforms/index_submissions.html', { + 'form_page': form_page, + 'select_date_form': select_date_form, + 'submissions': submissions, + 'data_headings': data_headings, + 'data_rows': data_rows + }) diff --git a/wagtail/wagtailforms/wagtail_hooks.py b/wagtail/wagtailforms/wagtail_hooks.py new file mode 100644 index 000000000..277fc6e73 --- /dev/null +++ b/wagtail/wagtailforms/wagtail_hooks.py @@ -0,0 +1,28 @@ +from django.core import urlresolvers +from django.conf import settings +from django.conf.urls import include, url +from django.utils.translation import ugettext_lazy as _ + +from wagtail.wagtailadmin import hooks +from wagtail.wagtailadmin.menu import MenuItem + +from wagtail.wagtailforms import urls +from wagtail.wagtailforms.models import get_forms_for_user + +def register_admin_urls(): + return [ + url(r'^forms/', include(urls)), + ] +hooks.register('register_admin_urls', register_admin_urls) + +def construct_main_menu(request, menu_items): + # show this only if the user has permission to retrieve submissions for at least one form + if get_forms_for_user(request.user).exists(): + menu_items.append( + MenuItem(_('Forms'), urlresolvers.reverse('wagtailforms_index'), classnames='icon icon-form', order=700) + ) +hooks.register('construct_main_menu', construct_main_menu) + +def editor_js(): + return """""" % settings.STATIC_URL +hooks.register('insert_editor_js', editor_js) diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index f0be0fe4d..24fd1f0bf 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -4,7 +4,7 @@ import os.path from taggit.managers import TaggableManager from django.core.files import File -from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist, ValidationError +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.db import models from django.db.models.signals import pre_delete from django.dispatch.dispatcher import receiver @@ -17,6 +17,8 @@ from unidecode import unidecode from wagtail.wagtailadmin.taggable import TagSearchable from wagtail.wagtailimages.backends import get_image_backend +from .utils import validate_image_format + class AbstractImage(models.Model, TagSearchable): title = models.CharField(max_length=255, verbose_name=_('Title') ) @@ -34,12 +36,7 @@ class AbstractImage(models.Model, TagSearchable): filename = prefix[:-1] + dot + extension return os.path.join(folder_name, filename) - def file_extension_validator(ffile): - extension = ffile.name.split(".")[-1].lower() - if extension not in ["gif", "jpg", "jpeg", "png"]: - raise ValidationError(_("Not a valid image format. Please use a gif, jpeg or png file instead.")) - - file = models.ImageField(verbose_name=_('File'), upload_to=get_upload_to, width_field='width', height_field='height', validators=[file_extension_validator]) + file = models.ImageField(verbose_name=_('File'), upload_to=get_upload_to, width_field='width', height_field='height', validators=[validate_image_format]) width = models.IntegerField(editable=False) height = models.IntegerField(editable=False) created_at = models.DateTimeField(auto_now_add=True) diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index 9c4ce6ea9..00d1245e6 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -4,9 +4,7 @@ from django.contrib.auth.models import User, Group, Permission from django.core.urlresolvers import reverse from django.core.files.uploadedfile import SimpleUploadedFile -import unittest2 as unittest - -from wagtail.tests.utils import login +from wagtail.tests.utils import login, unittest from wagtail.wagtailimages.models import get_image_model from wagtail.wagtailimages.templatetags import image_tags diff --git a/wagtail/wagtailimages/utils.py b/wagtail/wagtailimages/utils.py new file mode 100644 index 000000000..2f0c0e676 --- /dev/null +++ b/wagtail/wagtailimages/utils.py @@ -0,0 +1,28 @@ +import os + +from PIL import Image + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + + +def validate_image_format(f): + # Check file extension + extension = os.path.splitext(f.name)[1].lower()[1:] + + if extension == 'jpg': + extension = 'jpeg' + + if extension not in ['gif', 'jpeg', 'png']: + raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension.")) + + if not f.closed: + # Open image file + file_position = f.tell() + f.seek(0) + image = Image.open(f) + f.seek(file_position) + + # Check that the internal format matches the extension + if image.format.upper() != extension.upper(): + raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension.") % (extension.upper())) diff --git a/wagtail/wagtailimages/wagtail_hooks.py b/wagtail/wagtailimages/wagtail_hooks.py index f14d22904..51492d404 100644 --- a/wagtail/wagtailimages/wagtail_hooks.py +++ b/wagtail/wagtailimages/wagtail_hooks.py @@ -1,5 +1,7 @@ +from django.conf import settings from django.conf.urls import include, url 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 @@ -21,3 +23,23 @@ def construct_main_menu(request, menu_items): MenuItem(_('Images'), urlresolvers.reverse('wagtailimages_index'), classnames='icon icon-image', order=300) ) hooks.register('construct_main_menu', construct_main_menu) + + +def editor_js(): + js_files = [ + 'wagtailimages/js/hallo-plugins/hallo-wagtailimage.js', + 'wagtailimages/js/image-chooser.js', + ] + js_includes = format_html_join('\n', '', + ((settings.STATIC_URL, filename) for filename in js_files) + ) + return js_includes + format_html( + """ + + """, + urlresolvers.reverse('wagtailimages_chooser') + ) +hooks.register('insert_editor_js', editor_js) diff --git a/wagtail/wagtailsearch/tests/test_backends.py b/wagtail/wagtailsearch/tests/test_backends.py index b8b6ee3b1..bd4a64926 100644 --- a/wagtail/wagtailsearch/tests/test_backends.py +++ b/wagtail/wagtailsearch/tests/test_backends.py @@ -2,7 +2,7 @@ from django.test import TestCase from django.test.utils import override_settings from django.conf import settings from django.core import management -import unittest2 as unittest +from wagtail.tests.utils import unittest from wagtail.wagtailsearch import models, get_search_backend from wagtail.wagtailsearch.backends.db import DBSearch from wagtail.wagtailsearch.backends import InvalidSearchBackendError diff --git a/wagtail/wagtailsearch/tests/test_queries.py b/wagtail/wagtailsearch/tests/test_queries.py index 2ff2945de..7086d46f4 100644 --- a/wagtail/wagtailsearch/tests/test_queries.py +++ b/wagtail/wagtailsearch/tests/test_queries.py @@ -1,9 +1,8 @@ from django.test import TestCase from django.core import management from wagtail.wagtailsearch import models -from wagtail.tests.utils import login +from wagtail.tests.utils import login, unittest from StringIO import StringIO -import unittest2 as unittest class TestHitCounter(TestCase): diff --git a/wagtail/wagtailsnippets/permissions.py b/wagtail/wagtailsnippets/permissions.py index 57441f356..5a730909b 100644 --- a/wagtail/wagtailsnippets/permissions.py +++ b/wagtail/wagtailsnippets/permissions.py @@ -19,10 +19,12 @@ def user_can_edit_snippet_type(user, content_type): def user_can_edit_snippets(user): """ true if user has any permission related to any content type registered as a snippet type """ + snippet_content_types = get_snippet_content_types() if user.is_active and user.is_superuser: - return True + # admin can edit snippets iff any snippet types exist + return bool(snippet_content_types) - permissions = Permission.objects.filter(content_type__in=get_snippet_content_types()).select_related('content_type') + permissions = Permission.objects.filter(content_type__in=snippet_content_types).select_related('content_type') for perm in permissions: permission_name = "%s.%s" % (perm.content_type.app_label, perm.codename) if user.has_perm(permission_name): diff --git a/wagtail/wagtailsnippets/tests.py b/wagtail/wagtailsnippets/tests.py index 0ba40fac9..782fe9087 100644 --- a/wagtail/wagtailsnippets/tests.py +++ b/wagtail/wagtailsnippets/tests.py @@ -5,7 +5,7 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ -import unittest2 as unittest +from wagtail.tests.utils import unittest from django.test import TestCase diff --git a/wagtail/wagtailsnippets/wagtail_hooks.py b/wagtail/wagtailsnippets/wagtail_hooks.py index 35468f7d8..3745d1a51 100644 --- a/wagtail/wagtailsnippets/wagtail_hooks.py +++ b/wagtail/wagtailsnippets/wagtail_hooks.py @@ -1,5 +1,7 @@ +from django.conf import settings from django.conf.urls import include, url from django.core import urlresolvers +from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ from wagtail.wagtailadmin import hooks @@ -22,3 +24,15 @@ def construct_main_menu(request, menu_items): MenuItem(_('Snippets'), urlresolvers.reverse('wagtailsnippets_index'), classnames='icon icon-snippet', order=500) ) hooks.register('construct_main_menu', construct_main_menu) + + +def editor_js(): + return format_html(""" + + + """, + settings.STATIC_URL, + 'wagtailsnippets/js/snippet-chooser.js', + urlresolvers.reverse('wagtailsnippets_choose_generic') + ) +hooks.register('insert_editor_js', editor_js)