Skip to content

Commit

Permalink
Add the ability to easily copy existing FlatMenus via the Wagtail adm…
Browse files Browse the repository at this point in the history
…in area (#49)

* Use custom `clean()` method to raise useful ValidationError when unique_together isn't respected
* Adds FlatMenuCopyView to allow copying of an existing FlatMenu
* Adds 'copy' button to listing page to easily access for each FlatMenu

* Added a `link_handle` to `MenuItem` model (#39) (#41)

* Added a `link_handle` to `MenuItem` model (#39)
* Added documentation
* Added entries /credits to CHANGELOG.md and CONTRIBUTORS.md
* Improved consistency of 'new line usage'  in release notes
* Update screenshot of flat menu listing
* Added documentation
* Add `django-webtest` to test requirements and use to do some testing of the copy view
  • Loading branch information
Andy Babic committed Oct 4, 2016
1 parent 4edfd17 commit a1ed0f0
Show file tree
Hide file tree
Showing 11 changed files with 228 additions and 13 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ Changelog
allow flat menus defined for the default site to be used as fall-backs, in
cases where the 'current' site doesn't have its own menus set up with the
specified handle.
* Added a custom ValidationError to FlatMenu's `clean()` method that better
handles the `unique_together` rule that applied to `site` and `handle`
fields.
* Added the ability to copy/duplicate existing FlatMenu objects between sites
(or to the same site with a different handle) via Wagtail's admin area. The
'Copy' button appears in the listing for anyone with 'add' permission, and
the view allows the user to make changes before anything is saved.
* Apply `active` classes to menu items that link to custom URLs (if
`request.path` and `link_url` are exact matches).
* Added a `handle` to `MenuItem` model to provide a string which can be
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Have you ever hard-coded a menu into a footer at the start of a project, only fo

Flat menus are designed for outputting simple, flat lists of links, but they CAN be made to display multiple levels of pages too. See the instructions below for [using the `{% flat_menu %}` tag](#flat_menu-tag).

In a multi-site project, you can choose to define a new set of menus for each site, or you can define one set of menus for your default site and reuse them for your other sites, or use a combination of both approaches for different menus (see the **`fall_back_to_default_site_menus`** option in [**Using the `{% flat_menu %}` tag**](#flat_menu-tag) to find out more). However you choose to do things, a 'copy' feature makes it easy to copy existing flat menus from one site to another via Wagtail's admin interface.

### 3. Offers a solution to the issue of key page links becoming 'toggles' in multi-level drop-down menus

Extend the `wagtailmenus.models.MenuPage` model instead of the usual `wagtail.wagtailcore.models.Page` to create your custom page types, and gain a couple of extra fields that will allow you to configure certain pages to appear again alongside their children in multi-level menus. Use the menu tags provided, and that behaviour will remain consistent in all menus throughout your site.
Expand Down Expand Up @@ -89,11 +91,13 @@ Since version `1.2`, watailmenus has depended on the `wagtail.contrib.modeladmin
### <a id="defining-flat-menus"></a>2. Defining flat menus in the CMS

1. Log into the Wagtail CMS for your project (as a superuser).
2. Click on `Settings` in the side menu to access the options in there, then select `Flat menus`.
2. Click on `Settings` in the side menu to access the options in there, then select `Flat menus` to access the menu list page.
3. Click the button at the top of the page to add a flat menu for your site (or one for each of your sites if you are running a multi-site setup). <img alt="Screenshot showing the FlatMenu edit interface" src="https://raw.githubusercontent.com/rkhleics/wagtailmenus/master/screenshots/wagtailmenus-flatmenu-edit.png" />
4. Fill out the form, choosing a 'unique for site' `handle` to reference in your templates.
5. Use the **MENU ITEMS** inline panel to define the links you want the menu to have. If you wish, you can use the `handle` field to specify an additional value for each item, which you'll be able to access in a custom flat menu template. **NOTE**: Pages need to be published and have the `show_in_menus` checkbox checked in order to appear in menus (look under the **Promote** tab when editing pages).
6. Save your changes to apply them to your site.
6. Save your changes to apply them to your site.

All of the flat menus created for a project will appear in the menu list page (from step 2, above) making it easy to find, update, copy or delete your menus later. As soon as you create menus for more than one site in a multi-site project, the listing page will give you additional information and filters to help manage your menus, like so: <img alt="Screenshot showing the FlatMenu listing page for a multi-site setup" src="https://raw.githubusercontent.com/rkhleics/wagtailmenus/master/screenshots/wagtailmenus-flatmenu-list.png" />

### <a id="main_menu-tag"></a>3. Using the `{% main_menu %}` tag

Expand Down
1 change: 1 addition & 0 deletions requirements/testing.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
-r base.txt

django-webtest>=1.8.0
beautifulsoup4>=4.4.1,<4.5.0
coverage>=3.6
tox
Binary file modified screenshots/wagtailmenus-flatmenu-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ basepython =
deps =
coverage>=3.6
beautifulsoup4>=4.4.1,<4.5.0
django-webtest>=1.8.0
dj18: Django>=1.8.1,<1.9
dj19: Django>=1.9,<1.10
dj110: Django>=1.10a1,<1.11
Expand Down
17 changes: 16 additions & 1 deletion wagtailmenus/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,27 @@ def get_for_site(cls, handle, site,
def __str__(self):
return '%s (%s)' % (self.title, self.handle)

def clean(self, *args, **kwargs):
# Raise validation error for unique_together constraint, as it's not
# currently handled properly by wagtail
clashes = FlatMenu.objects.filter(site=self.site, handle=self.handle)
if self.pk:
clashes = clashes.exclude(pk__exact=self.pk)
if clashes.count():
msg = _("Site and handle must create a unique combination. A menu "
"already exists with these same two values.")
raise ValidationError({
'site': [msg],
'handle': [msg],
})
super(FlatMenu, self).clean(*args, **kwargs)

panels = (
MultiFieldPanel(
heading=_("Settings"),
children=(
FieldPanel('site'),
FieldPanel('title'),
FieldPanel('site'),
FieldPanel('handle'),
FieldPanel('heading'),
)
Expand Down
2 changes: 1 addition & 1 deletion wagtailmenus/settings/testing.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .base import * # NOQA

DEBUG = True
DEBUG = False
SITE_ID = 1

DATABASES = {
Expand Down
4 changes: 4 additions & 0 deletions wagtailmenus/templates/wagtailmenus/flatmenu_copy.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "modeladmin/create.html" %}
{% load i18n %}

{% block form_action %}{{ view.copy_url }}{% endblock %}
101 changes: 100 additions & 1 deletion wagtailmenus/tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,77 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.test import TransactionTestCase
from django_webtest import WebTest
from wagtail.wagtailcore.models import Site
from wagtailmenus.models import FlatMenu


class CMSUsecaseTests(WebTest):

# optional: we want some initial data to be able to login
fixtures = ['test.json']

def test_copy_footer_menu(self):
get_user_model().objects._create_user(
username='test1', email='test1@email.com', password='password',
is_staff=True, is_superuser=True)

site_one = Site.objects.get(id=1)
site_two = Site.objects.get(id=2)

# Start by getting the footer menu for site one
site_one_footer_menu = FlatMenu.get_for_site('footer', site_one)
copy_view = self.app.get(
'/admin/wagtailmenus/flatmenu/copy/%s/' % site_one_footer_menu.pk,
user='test1')

form = copy_view.forms[1]
form['site'] = site_two.pk
response = form.submit().follow()

assert len(response.context['object_list']) == 3
assert '<div class="changelist-filter col3">' in response

# Let's just compare the two menu with the old one
site_two_footer_menu = FlatMenu.get_for_site('footer', site_two)

assert site_one_footer_menu.pk != site_two_footer_menu.pk
assert site_one_footer_menu.heading == site_two_footer_menu.heading
assert site_one_footer_menu.menu_items.count() == site_two_footer_menu.menu_items.count()

def test_cannot_copy_footer_menu(self):
get_user_model().objects._create_user(
username='test1', email='test1@email.com', password='password',
is_staff=True, is_superuser=True)

site_one = Site.objects.get(id=1)
site_two = Site.objects.get(id=2)
# Start by getting the footer menu for site one
site_one_footer_menu = FlatMenu.get_for_site('footer', site_one)
# Create a new menu from the above one, for site two
site_two_footer_menu = site_one_footer_menu
site_two_footer_menu.id = None
site_two_footer_menu.site = site_two
site_two_footer_menu.save()
# Refetche menu one
site_one_footer_menu = FlatMenu.get_for_site('footer', site_one)

copy_view = self.app.get(
'/admin/wagtailmenus/flatmenu/copy/%s/' % site_one_footer_menu.pk,
user='test1')
form = copy_view.forms[1]
form['site'] = site_two.pk
response = form.submit()

assert 'The flat menu could not be saved due to errors' in response
assert 'Site and handle must create a unique combination.' in response


class TestSuperUser(TransactionTestCase):
fixtures = ['test.json']

def setUp(self):
user = get_user_model().objects._create_user(
get_user_model().objects._create_user(
username='test1', email='test1@email.com', password='password',
is_staff=True, is_superuser=True)
self.client.login(username='test1', password='password')
Expand Down Expand Up @@ -63,12 +126,48 @@ def test_mainmenu_edit_multisite(self):
def test_flatmenu_list(self):
response = self.client.get('/admin/wagtailmenus/flatmenu/')
self.assertEqual(response.status_code, 200)
self.assertNotContains(
response, '<th scope="col" class="sortable column-site">')
self.assertNotContains(response,
'<div class="changelist-filter col3">')

def test_flatmenu_list_multisite(self):
site_one = Site.objects.get(id=1)
site_two = Site.objects.get(id=2)

# Start by getting the footer menu for site one
site_one_footer_menu = FlatMenu.get_for_site('footer', site_one)

# Use menu one to create another for site two
site_two_footer_menu = site_one_footer_menu
site_two_footer_menu.site = site_two
site_two_footer_menu.id = None
site_two_footer_menu.save()

# Redefine menu one, so that we definitely have two menus
site_one_footer_menu = FlatMenu.get_for_site('footer', site_one)

# Check the menus aren't the same
self.assertNotEqual(site_one_footer_menu, site_two_footer_menu)

# Check that the listing has changed to include the site column and
# filters
response = self.client.get('/admin/wagtailmenus/flatmenu/')
self.assertEqual(response.status_code, 200)
self.assertContains(
response, '<th scope="col" class="sortable column-site">')
self.assertContains(response, '<div class="changelist-filter col3">')

def test_flatmenu_edit(self):
response = self.client.get(
'/admin/wagtailmenus/flatmenu/edit/1/')
self.assertEqual(response.status_code, 200)

def test_flatmenu_copy(self):
response = self.client.get(
'/admin/wagtailmenus/flatmenu/copy/1/')
self.assertEqual(response.status_code, 200)


class TestNonSuperUser(TransactionTestCase):
fixtures = ['perms.json', 'test.json']
Expand Down
48 changes: 45 additions & 3 deletions wagtailmenus/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import absolute_import, unicode_literals
from copy import copy

from django import forms
from django.utils.text import capfirst
Expand All @@ -10,8 +11,8 @@
from wagtail.wagtailadmin import messages
from wagtail.wagtailcore.models import Site

from wagtail.contrib.modeladmin.views import WMABaseView, ModelFormView
from .models import MainMenu
from wagtail.contrib.modeladmin.views import (
WMABaseView, EditView, ModelFormView)


class SiteSwitchForm(forms.Form):
Expand Down Expand Up @@ -51,7 +52,7 @@ def __init__(self, model_admin, instance_pk):
self.site = get_object_or_404(Site, id=self.instance_pk)
self.edit_url = self.model_admin.url_helper.get_action_url(
'edit', self.instance_pk)
self.instance = MainMenu.get_for_site(self.site)
self.instance = self.model.get_for_site(self.site)
self.instance.save()

def get_meta_title(self):
Expand Down Expand Up @@ -94,3 +95,44 @@ def get_error_message(self):

def get_template_names(self):
return ['wagtailmenus/mainmenu_edit.html']


class FlatMenuCopyView(EditView):
page_title = _('Copying')

@property
def copy_url(self):
return self.url_helper.get_action_url('copy', self.pk_quoted)

def get_meta_title(self):
return _('Copying %s') % self.opts.verbose_name

def check_action_permitted(self, user):
return self.permission_helper.user_can_create(user)

def get_form_kwargs(self):
kwargs = super(FlatMenuCopyView, self).get_form_kwargs()
"""
When the form is posted, don't pass an instance to the form. It should
create a new one out of the posted data. We also need to nullify any
IDs posted for inline menu items, so that new instances of those are
created too.
"""
if self.request.method == 'POST':
data = copy(self.request.POST)
i = 0
while(data.get('menu_items-%s-id' % i)):
data['menu_items-%s-id' % i] = None
i += 1
kwargs.update({
'data': data,
'instance': self.model()
})
return kwargs

def get_success_message(self, instance):
return _("{model_name} '{instance}' created.").format(
model_name=capfirst(self.opts.verbose_name), instance=instance)

def get_template_names(self):
return ['wagtailmenus/flatmenu_copy.html']
52 changes: 47 additions & 5 deletions wagtailmenus/wagtail_hooks.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from django.conf.urls import url
from django.contrib.admin.utils import quote
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _

from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
from wagtail.contrib.modeladmin.helpers import ButtonHelper
from wagtail.wagtailcore import hooks

from .app_settings import (
MAINMENU_MENU_ICON, FLATMENU_MENU_ICON, SECTION_ROOT_DEPTH)
from .models import MainMenu, FlatMenu
from .views import MainMenuIndexView, MainMenuEditView
from .views import (
MainMenuIndexView, MainMenuEditView, FlatMenuCopyView)


class MainMenuAdmin(ModelAdmin):
Expand All @@ -32,23 +35,59 @@ def get_admin_urls_for_registration(self):
modeladmin_register(MainMenuAdmin)


class FlatMenuButtonHelper(ButtonHelper):

def copy_button(self, pk, classnames_add=[], classnames_exclude=[]):
cn = self.finalise_classname(classnames_add, classnames_exclude)
return {
'url': self.url_helper.get_action_url('copy', quote(pk)),
'label': _('Copy'),
'classname': cn,
'title': _('Copy this %s') % self.verbose_name,
}

def get_buttons_for_obj(self, obj, exclude=[], classnames_add=[],
classnames_exclude=[]):
ph = self.permission_helper
usr = self.request.user
pk = quote(getattr(obj, self.opts.pk.attname))
btns = super(FlatMenuButtonHelper, self).get_buttons_for_obj(
obj, exclude, classnames_add, classnames_exclude)
if('copy' not in exclude and ph.user_can_create(usr)):
btns.append(
self.copy_button(pk, classnames_add, classnames_exclude)
)
return btns


class FlatMenuAdmin(ModelAdmin):
model = FlatMenu
menu_label = _('Flat menus')
menu_icon = FLATMENU_MENU_ICON
button_helper_class = FlatMenuButtonHelper
ordering = ('-site__is_default_site', 'site__hostname', 'handle')
add_to_settings_menu = True

def is_multisite(self, request):
return self.get_queryset(request).values('site').distinct().count() > 1
def copy_view(self, request, instance_pk):
kwargs = {'model_admin': self, 'instance_pk': instance_pk}
return FlatMenuCopyView.as_view(**kwargs)(request)

def get_admin_urls_for_registration(self):
urls = super(FlatMenuAdmin, self).get_admin_urls_for_registration()
urls += (
url(self.url_helper.get_action_url_pattern('copy'),
self.copy_view,
name=self.url_helper.get_action_url_name('copy')),
)
return urls

def get_list_filter(self, request):
if self.is_multisite(request):
if self.is_multisite_listing(request):
return ('site', 'handle')
return ()

def get_list_display(self, request):
if self.is_multisite(request):
if self.is_multisite_listing(request):
return ('title', 'handle_formatted', 'site', 'items')
return ('title', 'handle_formatted', 'items')

Expand All @@ -57,6 +96,9 @@ def handle_formatted(self, obj):
handle_formatted.short_description = 'handle'
handle_formatted.admin_order_field = 'handle'

def is_multisite_listing(self, request):
return self.get_queryset(request).values('site').distinct().count() > 1

def items(self, obj):
return obj.menu_items.count()
items.short_description = _('no. of items')
Expand Down

0 comments on commit a1ed0f0

Please sign in to comment.