diff --git a/docs/contributing/styleguide.rst b/docs/contributing/styleguide.rst index a2357e5fcc..9c3e1fcda4 100644 --- a/docs/contributing/styleguide.rst +++ b/docs/contributing/styleguide.rst @@ -1,3 +1,5 @@ +.. _styleguide: + UI Styleguide ============= @@ -15,4 +17,4 @@ To install the styleguide module on your site, add it to the list of ``INSTALLED At present the styleguide is static: new UI components must be added to it manually, and there are no hooks into it for other modules to use. We hope to support hooks in the future. -The styleguide doesn't currently provide examples of all the core interface components; notably the Page, Document, Image and Snippet chooser interfaces are not currently represented. \ No newline at end of file +The styleguide doesn't currently provide examples of all the core interface components; notably the Page, Document, Image and Snippet chooser interfaces are not currently represented. diff --git a/docs/reference/contrib/index.rst b/docs/reference/contrib/index.rst index 7996d83dc5..e1b5c183ee 100644 --- a/docs/reference/contrib/index.rst +++ b/docs/reference/contrib/index.rst @@ -7,6 +7,7 @@ Wagtail ships with a variety of extra optional modules. .. toctree:: :maxdepth: 2 + settings forms staticsitegen sitemaps @@ -16,6 +17,12 @@ Wagtail ships with a variety of extra optional modules. searchpromotions +:doc:`settings` +--------------- + +Site-wide settings that are editable by administrators in the Wagtail admin. + + :doc:`forms` ------------ diff --git a/docs/reference/contrib/settings.rst b/docs/reference/contrib/settings.rst new file mode 100644 index 0000000000..82a8d9118b --- /dev/null +++ b/docs/reference/contrib/settings.rst @@ -0,0 +1,128 @@ +.. _settings: + +============= +Site settings +============= + +You can define settings for your site that are editable by administrators in the Wagtail admin. These settings can be accessed in code, as well as in templates. + +To use these settings, you must add ``wagtail.contrib.settings`` to your ``INSTALLED_APPS``: + +.. code-block:: python + + INSTALLED_APPS += [ + 'wagtail.contrib.settings', + ] + + +Defining settings +================= + +Create a model that inherits from ``BaseSetting``, and register it using the ``register_setting`` decorator: + +.. code-block:: python + + from wagtail.contrib.settings.models import BaseSetting, register_setting + + @register_setting + class SocialMediaSettings(BaseSetting): + facebook = models.URLField( + help_text='Your Facebook page URL') + instagram = models.CharField( + max_length=255, help_text='Your Instagram username, without the @') + trip_advisor = models.URLField( + help_text='Your Trip Advisor page URL') + youtube = models.URLField( + help_text='Your YouTube channel or user account URL') + + +A 'Social media settings' link will appear in the Wagtail admin 'Settings' menu. + +Edit handlers +------------- + +Settings use edit handlers much like the rest of Wagtail. Add a ``panels`` setting to your model defining all the edit handlers required: + +.. code-block:: python + + @register_setting + class ImportantPages(BaseSetting): + donate_page = models.ForeignKey( + 'wagtailcore.Page', null=True, on_delete=models.SET_NULL) + sign_up_page = models.ForeignKey( + 'wagtailcore.Page', null=True, on_delete=models.SET_NULL) + + panels = [ + PageChooserPanel('donate_page'), + PageChooserPanel('sign_up_page'), + ] + +Appearance +---------- + +You can change the label used in the menu by changing the `verbose_name `_ of your model. + +You can add an icon to the menu by passing an 'icon' argument to the ``register_setting`` decorator: + +.. code-block:: python + + @register_setting(icon='icon-placeholder') + class SocialMediaSettings(BaseSetting): + class Meta: + verbose_name = 'Social media accounts' + ... + +For a list of all available icons, please see the :ref:`styleguide`. + +Using the settings +================== + +Settings are designed to be used both in Python code, and in templates. + +Using in Python +--------------- + +If access to a setting is required in the code, the :func:`~wagtail.contrib.settings.models.BaseSetting.for_site` method will retrieve the setting for the supplied site: + +.. code-block:: python + + def view(request): + social_media_settings = SocialMediaSettings.for_site(request.site) + ... + +Using in templates +------------------ + +Add the ``request`` and ``settings`` context processors to your settings: + +.. code-block:: python + + from django.conf import global_settings + TEMPLATE_CONTEXT_PROCESSORS = global_settings.TEMPLATE_CONTEXT_PROCESSORS + [ + 'django.core.context_processors.request', + 'wagtail.contrib.settings.context_processors.settings', + ] + +Then access the settings through ``{{ settings }}``: + +.. code-block:: html+django + + {{ settings.app_label.SocialMediaSettings.instagram }} + +If you are not in a ``RequestContext``, then context processors will not have run, and the ``settings`` variable will not be availble. To get the ``settings``, use the provided ``{% get_settings %}`` template tag. If a ``request`` is in the template context, but for some reason it is not a ``RequestContext``, just use ``{% get_settings %}``: + +.. code-block:: html+django + + {% load wagtailsettings_tags %} + {% get_settings %} + {{ settings.app_label.SocialMediaSettings.instagram }} + +If there is no ``request`` available in the template at all, you can use the settings for the default Wagtail site instead: + +.. code-block:: html+django + + {% load wagtailsettings_tags %} + {% get_settings use_default_site=True %} + {{ settings.app_label.SocialMediaSettings.instagram }} + +.. note:: You can not reliably get the correct settings instance for the current site from this template tag if the request object is not available. This is only relevant for multisite instances of Wagtail. diff --git a/gulpfile.js/config.js b/gulpfile.js/config.js index 73b08728b9..4c6da997dd 100644 --- a/gulpfile.js/config.js +++ b/gulpfile.js/config.js @@ -54,6 +54,7 @@ var apps = [ 'wagtailstyleguide/scss/styleguide.scss' ], }), + new App('wagtail/contrib/settings'), ]; module.exports = { diff --git a/wagtail/contrib/settings/.gitignore b/wagtail/contrib/settings/.gitignore new file mode 100644 index 0000000000..980c85122d --- /dev/null +++ b/wagtail/contrib/settings/.gitignore @@ -0,0 +1 @@ +static/ diff --git a/wagtail/contrib/settings/__init__.py b/wagtail/contrib/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/wagtail/contrib/settings/apps.py b/wagtail/contrib/settings/apps.py new file mode 100644 index 0000000000..f907279ffe --- /dev/null +++ b/wagtail/contrib/settings/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class WagtailSettingsAppConfig(AppConfig): + name = 'wagtail.contrib.settings' + label = 'wagtailsettings' + verbose_name = "Wagtail site settings" diff --git a/wagtail/contrib/settings/context_processors.py b/wagtail/contrib/settings/context_processors.py new file mode 100644 index 0000000000..26d7be661a --- /dev/null +++ b/wagtail/contrib/settings/context_processors.py @@ -0,0 +1,56 @@ +from django.utils.encoding import python_2_unicode_compatible + +from .registry import registry + + +@python_2_unicode_compatible +class SettingsProxy(dict): + """ + Get a SettingModuleProxy for an app using proxy['app_label'] + """ + def __init__(self, site): + self.site = site + + def __missing__(self, app_label): + self[app_label] = value = SettingModuleProxy(self.site, app_label) + return value + + def __str__(self): + return 'SettingsProxy' + + +@python_2_unicode_compatible +class SettingModuleProxy(dict): + """ + Get a setting instance using proxy['modelname'] + """ + def __init__(self, site, app_label): + self.site = site + self.app_label = app_label + + def __getitem__(self, model_name): + """ Get a setting instance for a model """ + # Model names are treated as case-insensitive + return super(SettingModuleProxy, self).__getitem__(model_name.lower()) + + def __missing__(self, model_name): + """ Get and cache settings that have not been looked up yet """ + self[model_name] = value = self.get_setting(model_name) + return value + + def get_setting(self, model_name): + """ + Get a setting instance + """ + Model = registry.get_by_natural_key(self.app_label, model_name) + if Model is None: + return None + + return Model.for_site(self.site) + + def __str__(self): + return 'SettingsModuleProxy({0})'.format(self.app_label) + + +def settings(request): + return {'settings': SettingsProxy(request.site)} diff --git a/wagtail/contrib/settings/forms.py b/wagtail/contrib/settings/forms.py new file mode 100644 index 0000000000..d6cb2e29ba --- /dev/null +++ b/wagtail/contrib/settings/forms.py @@ -0,0 +1,27 @@ +from __future__ import absolute_import, unicode_literals + +from django import forms +from django.core.urlresolvers import reverse + +from wagtail.wagtailcore.models import Site + + +class SiteSwitchForm(forms.Form): + site = forms.ChoiceField(choices=[]) + + class Media: + js = [ + 'settings/js/site-switcher.js', + ] + + def __init__(self, current_site, model, **kwargs): + initial_data = {'site': self.get_change_url(current_site, model)} + super(SiteSwitchForm, self).__init__(initial=initial_data, **kwargs) + sites = [(self.get_change_url(site, model), site) + for site in Site.objects.all()] + self.fields['site'].choices = sites + + @classmethod + def get_change_url(cls, site, model): + return reverse('wagtailsettings_edit', args=[ + site.pk, model._meta.app_label, model._meta.model_name]) diff --git a/wagtail/contrib/settings/models.py b/wagtail/contrib/settings/models.py new file mode 100644 index 0000000000..bc6584cfe0 --- /dev/null +++ b/wagtail/contrib/settings/models.py @@ -0,0 +1,25 @@ +from django.db import models +from .registry import register_setting + +__all__ = ['BaseSetting', 'register_setting'] + + +class BaseSetting(models.Model): + """ + The abstract base model for settings. Subclasses must be registered using + :func:`~wagtail.contrib.settings.registry.register_setting` + """ + + site = models.OneToOneField( + 'wagtailcore.Site', unique=True, db_index=True, editable=False) + + class Meta: + abstract = True + + @classmethod + def for_site(cls, site): + """ + Get an instance of this setting for the site. + """ + instance, created = cls.objects.get_or_create(site=site) + return instance diff --git a/wagtail/contrib/settings/permissions.py b/wagtail/contrib/settings/permissions.py new file mode 100644 index 0000000000..227dd5be79 --- /dev/null +++ b/wagtail/contrib/settings/permissions.py @@ -0,0 +1,4 @@ +def user_can_edit_setting_type(user, model): + """ Check if a user has permission to edit this setting type """ + return user.has_perm("{}.change_{}".format( + model._meta.app_label, model._meta.model_name)) diff --git a/wagtail/contrib/settings/registry.py b/wagtail/contrib/settings/registry.py new file mode 100644 index 0000000000..a460de0345 --- /dev/null +++ b/wagtail/contrib/settings/registry.py @@ -0,0 +1,81 @@ +from django.apps import apps +from django.contrib.auth.models import Permission +from django.core.urlresolvers import reverse +from django.utils.text import capfirst + +from wagtail.wagtailadmin.menu import MenuItem +from wagtail.wagtailcore import hooks +from .permissions import user_can_edit_setting_type + + +class SettingMenuItem(MenuItem): + def __init__(self, model, icon='cog', classnames='', **kwargs): + + icon_classes = 'icon icon-' + icon + if classnames: + classnames += ' ' + icon_classes + else: + classnames = icon_classes + + self.model = model + super(SettingMenuItem, self).__init__( + label=capfirst(model._meta.verbose_name), + url=reverse('wagtailsettings_edit', args=[ + model._meta.app_label, model._meta.model_name]), + classnames=classnames, + **kwargs) + + def is_shown(self, request): + return user_can_edit_setting_type(request.user, self.model) + + +class Registry(list): + + def register(self, model, **kwargs): + """ + Register a model as a setting, adding it to the wagtail admin menu + """ + + # Don't bother registering this if it is already registered + if model in self: + return model + self.append(model) + + # Register a new menu item in the settings menu + @hooks.register('register_settings_menu_item') + def menu_hook(): + return SettingMenuItem(model, **kwargs) + + @hooks.register('register_permissions') + def permissions_hook(): + return Permission.objects.filter( + content_type__app_label=model._meta.app_label, + codename='change_{}'.format(model._meta.model_name)) + + return model + + def register_decorator(self, model=None, **kwargs): + """ + Register a model as a setting in the Wagtail admin + """ + if model is None: + return lambda model: self.register(model, **kwargs) + return self.register(model, **kwargs) + + def get_by_natural_key(self, app_label, model_name): + """ + Get a setting model using its app_label and model_name. + + If the app_label.model_name combination is not a valid model, or the + model is not registered as a setting, returns None. + """ + try: + Model = apps.get_model(app_label, model_name) + except LookupError: + return None + if Model not in registry: + return None + return Model + +registry = Registry() +register_setting = registry.register_decorator diff --git a/wagtail/contrib/settings/static_src/settings/js/site-switcher.js b/wagtail/contrib/settings/static_src/settings/js/site-switcher.js new file mode 100644 index 0000000000..61d302c584 --- /dev/null +++ b/wagtail/contrib/settings/static_src/settings/js/site-switcher.js @@ -0,0 +1,12 @@ +$(function() { + var $switcher = $('form#settings-site-switch select'); + if (!$switcher.length) return; + + var initial = $switcher.val(); + $switcher.on('change', function() { + var url = $switcher.val(); + if (url != initial) { + window.location = url; + } + }); +}); diff --git a/wagtail/contrib/settings/templates/wagtailsettings/edit.html b/wagtail/contrib/settings/templates/wagtailsettings/edit.html new file mode 100644 index 0000000000..574b16efa0 --- /dev/null +++ b/wagtail/contrib/settings/templates/wagtailsettings/edit.html @@ -0,0 +1,53 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n %} +{% block titletag %}{% blocktrans %}Editing {{ setting_type_name}} - {{ instance }}{% endblocktrans %}{% endblock %} +{% block bodyclass %}menu-settings{% endblock %} +{% block content %} +
+
+
+
+

+ {% trans "Editing" %} + {{ setting_type_name|capfirst }} +

+
+
+
+ {% if site_switcher %} +
+ + {{ site_switcher.site }} +
+ {% endif %} +
+
+
+ +
+ {% csrf_token %} + {{ edit_handler.render_form_content }} + +
+
    +
  • + +
  • +
+
+
+ +{% endblock %} + +{% block extra_css %} + {% include "wagtailadmin/pages/_editor_css.html" %} + {{ form.media.css }} + {{ site_switcher.media.css }} +{% endblock %} +{% block extra_js %} + {% include "wagtailadmin/pages/_editor_js.html" %} + {{ form.media.js }} + {{ site_switcher.media.js }} +{% endblock %} diff --git a/wagtail/contrib/settings/templates/wagtailsettings/index.html b/wagtail/contrib/settings/templates/wagtailsettings/index.html new file mode 100644 index 0000000000..83718b766a --- /dev/null +++ b/wagtail/contrib/settings/templates/wagtailsettings/index.html @@ -0,0 +1,25 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n %} +{% block titletag %}{% trans "Settings" %}{% endblock %} +{% block bodyclass %}menu-settings{% endblock %} +{% block content %} + + {% include "wagtailadmin/shared/header.html" with title="Settings" icon="cogs" %} + +
+ +
+{% endblock %} diff --git a/wagtail/contrib/settings/templatetags/__init__.py b/wagtail/contrib/settings/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/wagtail/contrib/settings/templatetags/wagtailsettings_tags.py b/wagtail/contrib/settings/templatetags/wagtailsettings_tags.py new file mode 100644 index 0000000000..a22f31f10e --- /dev/null +++ b/wagtail/contrib/settings/templatetags/wagtailsettings_tags.py @@ -0,0 +1,21 @@ +from django.template import Library + +from wagtail.wagtailcore.models import Site + +from ..context_processors import SettingsProxy + +register = Library() + + +@register.simple_tag(takes_context=True) +def get_settings(context, use_default_site=False): + if use_default_site: + site = Site.objects.get(is_default_site=True) + elif 'request' in context: + site = context['request'].site + else: + raise RuntimeError('No request found in context, and use_default_site ' + 'flag not set') + + context['settings'] = SettingsProxy(site) + return '' diff --git a/wagtail/contrib/settings/tests/__init__.py b/wagtail/contrib/settings/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/wagtail/contrib/settings/tests/test_admin.py b/wagtail/contrib/settings/tests/test_admin.py new file mode 100644 index 0000000000..9d9c69489c --- /dev/null +++ b/wagtail/contrib/settings/tests/test_admin.py @@ -0,0 +1,194 @@ +from __future__ import absolute_import, unicode_literals + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.core.urlresolvers import reverse +from django.test import TestCase +from django.utils.text import capfirst + +from wagtail.contrib.settings.registry import SettingMenuItem +from wagtail.tests.testapp.models import IconSetting, TestSetting +from wagtail.tests.utils import WagtailTestUtils +from wagtail.wagtailcore import hooks +from wagtail.wagtailcore.models import Page, Site + + +class TestSettingMenu(TestCase, WagtailTestUtils): + + def login_only_admin(self): + """ Log in with a user that only has permission to access the admin """ + user = get_user_model().objects.create_user( + username='test', email='test@email.com', password='password') + user.user_permissions.add(Permission.objects.get_by_natural_key( + codename='access_admin', app_label='wagtailadmin', model='admin')) + self.client.login(username='test', password='password') + return user + + def test_menu_item_in_admin(self): + self.login() + response = self.client.get(reverse('wagtailadmin_home')) + + self.assertContains(response, capfirst(TestSetting._meta.verbose_name)) + self.assertContains(response, reverse('wagtailsettings_edit', args=('tests', 'testsetting'))) + + def test_menu_item_no_permissions(self): + self.login_only_admin() + response = self.client.get(reverse('wagtailadmin_home')) + + self.assertNotContains(response, TestSetting._meta.verbose_name) + self.assertNotContains(response, reverse('wagtailsettings_edit', args=('tests', 'testsetting'))) + + def test_menu_item_icon(self): + menu_item = SettingMenuItem(IconSetting, icon='tag', classnames='test-class') + classnames = set(menu_item.classnames.split(' ')) + self.assertEqual(classnames, {'icon', 'icon-tag', 'test-class'}) + + +class BaseTestSettingView(TestCase, WagtailTestUtils): + def get(self, site_pk=1, params={}): + url = self.edit_url('tests', 'testsetting', site_pk=site_pk) + return self.client.get(url, params) + + def post(self, site_pk=1, post_data={}): + url = self.edit_url('tests', 'testsetting', site_pk=site_pk) + return self.client.post(url, post_data) + + def edit_url(self, app, model, site_pk=1): + return reverse('wagtailsettings_edit', args=[site_pk, app, model]) + + +class TestSettingCreateView(BaseTestSettingView): + def setUp(self): + self.login() + + def test_status_code(self): + self.assertEqual(self.get().status_code, 200) + + def test_edit_invalid(self): + response = self.post(post_data={'foo': 'bar'}) + self.assertContains(response, "The setting could not be saved due to errors.") + self.assertContains(response, "This field is required.") + + def test_edit(self): + response = self.post(post_data={'title': 'Edited site title', + 'email': 'test@example.com'}) + self.assertEqual(response.status_code, 302) + + default_site = Site.objects.get(is_default_site=True) + setting = TestSetting.objects.get(site=default_site) + self.assertEqual(setting.title, 'Edited site title') + self.assertEqual(setting.email, 'test@example.com') + + +class TestSettingEditView(BaseTestSettingView): + def setUp(self): + default_site = Site.objects.get(is_default_site=True) + + self.test_setting = TestSetting() + self.test_setting.title = 'Site title' + self.test_setting.email = 'initial@example.com' + self.test_setting.site = default_site + self.test_setting.save() + + self.login() + + def test_status_code(self): + self.assertEqual(self.get().status_code, 200) + + def test_non_existant_model(self): + response = self.client.get(self.edit_url('test', 'foo')) + self.assertEqual(response.status_code, 404) + + def test_edit_invalid(self): + response = self.post(post_data={'foo': 'bar'}) + self.assertContains(response, "The setting could not be saved due to errors.") + self.assertContains(response, "This field is required.") + + def test_edit(self): + response = self.post(post_data={'title': 'Edited site title', + 'email': 'test@example.com'}) + self.assertEqual(response.status_code, 302) + + default_site = Site.objects.get(is_default_site=True) + setting = TestSetting.objects.get(site=default_site) + self.assertEqual(setting.title, 'Edited site title') + self.assertEqual(setting.email, 'test@example.com') + + +class TestMultiSite(BaseTestSettingView): + def setUp(self): + self.default_site = Site.objects.get(is_default_site=True) + self.other_site = Site.objects.create(hostname='example.com', root_page=Page.objects.get(pk=2)) + self.login() + + def test_redirect_to_default(self): + """ + Should redirect to the setting for the default site. + """ + start_url = reverse('wagtailsettings_edit', args=[ + 'tests', 'testsetting']) + dest_url = 'http://testserver' + reverse('wagtailsettings_edit', args=[ + self.default_site.pk, 'tests', 'testsetting']) + response = self.client.get(start_url, follow=True) + self.assertEqual([(dest_url, 302)], response.redirect_chain) + + def test_redirect_to_current(self): + """ + Should redirect to the setting for the current site taken from the URL, + by default + """ + start_url = reverse('wagtailsettings_edit', args=[ + 'tests', 'testsetting']) + dest_url = 'http://example.com' + reverse('wagtailsettings_edit', args=[ + self.other_site.pk, 'tests', 'testsetting']) + response = self.client.get(start_url, follow=True, HTTP_HOST=self.other_site.hostname) + self.assertEqual([(dest_url, 302)], response.redirect_chain) + + def test_switcher(self): + """ Check that the switcher form exists in the page """ + response = self.get() + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="settings-site-switch"') + + def test_unknown_site(self): + """ Check that unknown sites throw a 404 """ + response = self.get(site_pk=3) + self.assertEqual(response.status_code, 404) + + def test_edit(self): + """ + Check that editing settings in multi-site mode edits the correct + setting, and leaves the other ones alone + """ + TestSetting.objects.create( + title='default', + email='default@example.com', + site=self.default_site) + TestSetting.objects.create( + title='other', + email='other@example.com', + site=self.other_site) + response = self.post(site_pk=self.other_site.pk, post_data={ + 'title': 'other-new', 'email': 'other-other@example.com'}) + self.assertEqual(response.status_code, 302) + + # Check that the correct setting was updated + other_setting = TestSetting.for_site(self.other_site) + self.assertEqual(other_setting.title, 'other-new') + self.assertEqual(other_setting.email, 'other-other@example.com') + + # Check that the other setting was not updated + default_setting = TestSetting.for_site(self.default_site) + self.assertEqual(default_setting.title, 'default') + self.assertEqual(default_setting.email, 'default@example.com') + + +class TestAdminPermission(TestCase, WagtailTestUtils): + def test_registered_permission(self): + permission = Permission.objects.get_by_natural_key( + app_label='tests', model='testsetting', codename='change_testsetting') + for fn in hooks.get_hooks('register_permissions'): + if permission in fn(): + break + else: + self.fail('Change permission for tests.TestSetting not registered') diff --git a/wagtail/contrib/settings/tests/test_register.py b/wagtail/contrib/settings/tests/test_register.py new file mode 100644 index 0000000000..e817b40ffb --- /dev/null +++ b/wagtail/contrib/settings/tests/test_register.py @@ -0,0 +1,22 @@ +from django.core.urlresolvers import reverse +from django.test import TestCase + +from wagtail.contrib.settings.registry import Registry +from wagtail.tests.testapp.models import NotYetRegisteredSetting +from wagtail.tests.utils import WagtailTestUtils + + +class TestRegister(TestCase, WagtailTestUtils): + def setUp(self): + self.registry = Registry() + self.login() + + def test_register(self): + self.assertNotIn(NotYetRegisteredSetting, self.registry) + NowRegisteredSetting = self.registry.register_decorator(NotYetRegisteredSetting) + self.assertIn(NotYetRegisteredSetting, self.registry) + self.assertIs(NowRegisteredSetting, NotYetRegisteredSetting) + + def test_icon(self): + admin = self.client.get(reverse('wagtailadmin_home')) + self.assertContains(admin, 'icon icon-tag') diff --git a/wagtail/contrib/settings/tests/test_templates.py b/wagtail/contrib/settings/tests/test_templates.py new file mode 100644 index 0000000000..eb3ed2f49d --- /dev/null +++ b/wagtail/contrib/settings/tests/test_templates.py @@ -0,0 +1,153 @@ +from django.template import Context, RequestContext, Template +from django.test import TestCase + +from wagtail.tests.testapp.models import TestSetting +from wagtail.tests.utils import WagtailTestUtils +from wagtail.wagtailcore.models import Page, Site + + +class TemplateTestCase(TestCase, WagtailTestUtils): + def setUp(self): + root = Page.objects.first() + other_home = Page(title='Other Root', slug='other') + root.add_child(instance=other_home) + + self.default_site = Site.objects.get(is_default_site=True) + self.other_site = Site.objects.create(hostname='other', root_page=other_home) + + self.test_setting = TestSetting.objects.create( + title='Site title', + email='initial@example.com', + site=self.default_site) + + self.other_setting = TestSetting.objects.create( + title='Other title', + email='other@example.com', + site=self.other_site) + + def get_request(self, site=None): + if site is None: + site = self.default_site + request = self.client.get('/test/', HTTP_HOST=site.hostname) + request.site = site + return request + + def render(self, request, string, context=None, site=None): + template = Template(string) + context = RequestContext(request, context) + return template.render(context) + + +class TestContextProcessor(TemplateTestCase): + + def test_accessing_setting(self): + """ Check that the context processor works """ + request = self.get_request() + self.assertEqual( + self.render(request, '{{ settings.tests.TestSetting.title }}'), + self.test_setting.title) + + def test_multisite(self): + """ Check that the correct setting for the current site is returned """ + request = self.get_request(site=self.default_site) + self.assertEqual( + self.render(request, '{{ settings.tests.TestSetting.title }}'), + self.test_setting.title) + + request = self.get_request(site=self.other_site) + self.assertEqual( + self.render(request, '{{ settings.tests.TestSetting.title }}'), + self.other_setting.title) + + def test_model_case_insensitive(self): + """ Model names should be case insensitive """ + request = self.get_request() + self.assertEqual( + self.render(request, '{{ settings.tests.testsetting.title }}'), + self.test_setting.title) + self.assertEqual( + self.render(request, '{{ settings.tests.TESTSETTING.title }}'), + self.test_setting.title) + self.assertEqual( + self.render(request, '{{ settings.tests.TestSetting.title }}'), + self.test_setting.title) + self.assertEqual( + self.render(request, '{{ settings.tests.tEstsEttIng.title }}'), + self.test_setting.title) + + def test_models_cached(self): + """ Accessing a setting should only hit the DB once per render """ + request = self.get_request() + get_title = '{{ settings.tests.testsetting.title }}' + + for i in range(1, 4): + with self.assertNumQueries(1): + self.assertEqual( + self.render(request, get_title * i), + self.test_setting.title * i) + + +class TestTemplateTag(TemplateTestCase): + def test_no_context_processor(self): + """ + Assert that not running the context processor means settings are not in + the context, as expected. + """ + template = Template('{{ settings.tests.TestSetting.title }}') + context = Context() + self.assertEqual(template.render(context), '') + + def test_get_settings_request_context(self): + """ Check that the {% get_settings %} tag works """ + request = self.get_request(site=self.other_site) + context = Context({'request': request}) + + # This should use the site in the request + template = Template('{% load wagtailsettings_tags %}' + '{% get_settings %}' + '{{ settings.tests.testsetting.title}}') + + self.assertEqual(template.render(context), self.other_setting.title) + + def test_get_settings_request_context_use_default(self): + """ + Check that the {% get_settings use_default_site=True %} option + overrides a request in the context. + """ + request = self.get_request(site=self.other_site) + context = Context({'request': request}) + + # This should use the default site, ignoring the site in the request + template = Template('{% load wagtailsettings_tags %}' + '{% get_settings use_default_site=True %}' + '{{ settings.tests.testsetting.title}}') + + self.assertEqual(template.render(context), self.test_setting.title) + + def test_get_settings_use_default(self): + """ + Check that the {% get_settings use_default_site=True %} option works + """ + context = Context() + + # This should use the default site + template = Template('{% load wagtailsettings_tags %}' + '{% get_settings use_default_site=True %}' + '{{ settings.tests.testsetting.title}}') + + self.assertEqual(template.render(context), self.test_setting.title) + + def test_get_settings_no_request_no_default(self): + """ + Check that the {% get_settings %} throws an error if it can not find a + site to work with + """ + context = Context() + + # Without a request in the context, and without use_default_site, this + # should bail with an error + template = Template('{% load wagtailsettings_tags %}' + '{% get_settings %}' + '{{ settings.tests.testsetting.title}}') + with self.assertRaises(RuntimeError): + template.render(context) diff --git a/wagtail/contrib/settings/urls.py b/wagtail/contrib/settings/urls.py new file mode 100644 index 0000000000..767c717f07 --- /dev/null +++ b/wagtail/contrib/settings/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^(\w+)/(\w+)/$', views.edit_current_site, name='wagtailsettings_edit'), + url(r'^(\d+)/(\w+)/(\w+)/$', views.edit, name='wagtailsettings_edit'), +] diff --git a/wagtail/contrib/settings/views.py b/wagtail/contrib/settings/views.py new file mode 100644 index 0000000000..177ba6204b --- /dev/null +++ b/wagtail/contrib/settings/views.py @@ -0,0 +1,85 @@ +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.shortcuts import redirect, render, get_object_or_404 +from django.utils.lru_cache import lru_cache +from django.utils.text import capfirst +from django.utils.translation import ugettext as _ + +from wagtail.wagtailadmin import messages +from wagtail.wagtailadmin.edit_handlers import ( + ObjectList, extract_panel_definitions_from_model_class) +from wagtail.wagtailcore.models import Site + +from .forms import SiteSwitchForm +from .permissions import user_can_edit_setting_type +from .registry import registry + + +def get_model_from_url_params(app_name, model_name): + """ + retrieve a content type from an app_name / model_name combo. + Throw Http404 if not a valid setting type + """ + model = registry.get_by_natural_key(app_name, model_name) + if model is None: + raise Http404 + return model + + +@lru_cache() +def get_setting_edit_handler(model): + panels = extract_panel_definitions_from_model_class(model, ['site']) + return ObjectList(panels).bind_to_model(model) + + +def edit_current_site(request, app_name, model_name): + # Redirect the user to the edit page for the current site + return redirect('wagtailsettings_edit', request.site.pk, app_name, model_name) + + +def edit(request, site_pk, app_name, model_name): + model = get_model_from_url_params(app_name, model_name) + if not user_can_edit_setting_type(request.user, model): + raise PermissionDenied + site = get_object_or_404(Site, pk=site_pk) + + setting_type_name = model._meta.verbose_name + + instance = model.for_site(site) + edit_handler_class = get_setting_edit_handler(model) + form_class = edit_handler_class.get_form_class(model) + + if request.POST: + form = form_class(request.POST, request.FILES, instance=instance) + + if form.is_valid(): + form.save() + + messages.success( + request, + _("{setting_type} updated.").format( + setting_type=capfirst(setting_type_name), + instance=instance + ) + ) + return redirect('wagtailsettings_edit', site.pk, app_name, model_name) + else: + messages.error(request, _("The setting could not be saved due to errors.")) + edit_handler = edit_handler_class(instance=instance, form=form) + else: + form = form_class(instance=instance) + edit_handler = edit_handler_class(instance=instance, form=form) + + # Show a site switcher form if there are multiple sites + site_switcher = None + if Site.objects.count() > 1: + site_switcher = SiteSwitchForm(site, model) + + return render(request, 'wagtailsettings/edit.html', { + 'opts': model._meta, + 'setting_type_name': setting_type_name, + 'instance': instance, + 'edit_handler': edit_handler, + 'site': site, + 'site_switcher': site_switcher, + }) diff --git a/wagtail/contrib/settings/wagtail_hooks.py b/wagtail/contrib/settings/wagtail_hooks.py new file mode 100644 index 0000000000..2aa954df11 --- /dev/null +++ b/wagtail/contrib/settings/wagtail_hooks.py @@ -0,0 +1,12 @@ +from django.conf.urls import include, url + +from wagtail.wagtailcore import hooks + +from . import urls + + +@hooks.register('register_admin_urls') +def register_admin_urls(): + return [ + url(r'^settings/', include(urls)), + ] diff --git a/wagtail/tests/settings.py b/wagtail/tests/settings.py index d547d24115..9af3be45eb 100644 --- a/wagtail/tests/settings.py +++ b/wagtail/tests/settings.py @@ -53,6 +53,7 @@ if django.VERSION >= (1, 8): 'django.contrib.messages.context_processors.messages', 'django.template.context_processors.request', 'wagtail.tests.context_processors.do_not_use_static_url', + 'wagtail.contrib.settings.context_processors.settings', ], }, }, @@ -72,6 +73,7 @@ else: TEMPLATE_CONTEXT_PROCESSORS = global_settings.TEMPLATE_CONTEXT_PROCESSORS + ( 'django.core.context_processors.request', 'wagtail.tests.context_processors.do_not_use_static_url', + 'wagtail.contrib.settings.context_processors.settings', ) MIDDLEWARE_CLASSES = ( @@ -106,6 +108,7 @@ INSTALLED_APPS = ( 'wagtail.contrib.wagtailfrontendcache', 'wagtail.contrib.wagtailapi', 'wagtail.contrib.wagtailsearchpromotions', + 'wagtail.contrib.settings', 'wagtail.wagtailforms', 'wagtail.wagtailsearch', 'wagtail.wagtailembeds', diff --git a/wagtail/tests/testapp/migrations/0013_iconsetting_notyetregisteredsetting_testsetting.py b/wagtail/tests/testapp/migrations/0013_iconsetting_notyetregisteredsetting_testsetting.py new file mode 100644 index 0000000000..8b0051d4a7 --- /dev/null +++ b/wagtail/tests/testapp/migrations/0013_iconsetting_notyetregisteredsetting_testsetting.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0019_verbose_names_cleanup'), + ('tests', '0012_filepage'), + ] + + operations = [ + migrations.CreateModel( + name='IconSetting', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('site', models.OneToOneField(editable=False, to='wagtailcore.Site')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='NotYetRegisteredSetting', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('site', models.OneToOneField(editable=False, to='wagtailcore.Site')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TestSetting', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('title', models.CharField(max_length=100)), + ('email', models.EmailField(max_length=50)), + ('site', models.OneToOneField(editable=False, to='wagtailcore.Site')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py index cf03a34d43..a0bdbc394a 100644 --- a/wagtail/tests/testapp/models.py +++ b/wagtail/tests/testapp/models.py @@ -11,6 +11,7 @@ from modelcluster.fields import ParentalKey from modelcluster.models import ClusterableModel from modelcluster.contrib.taggit import ClusterTaggableManager +from wagtail.contrib.settings.models import BaseSetting, register_setting from wagtail.wagtailcore.models import Page, Orderable from wagtail.wagtailcore.fields import RichTextField, StreamField from wagtail.wagtailcore.blocks import CharBlock, RichTextBlock @@ -319,7 +320,6 @@ FormPage.content_panels = [ ] - # Snippets class AdvertPlacement(models.Model): page = ParentalKey('wagtailcore.Page', related_name='advert_placements') @@ -378,6 +378,7 @@ StandardChild.edit_handler = TabbedInterface([ ObjectList([], heading='Dinosaurs'), ]) + class BusinessIndex(Page): """ Can be placed anywhere, can only have Business children """ subpage_types = ['tests.BusinessChild', 'tests.BusinessSubIndex'] @@ -411,9 +412,11 @@ TaggedPage.content_panels = [ class PageChooserModel(models.Model): page = models.ForeignKey('wagtailcore.Page', help_text='help text') + class EventPageChooserModel(models.Model): page = models.ForeignKey('tests.EventPage', help_text='more help text') + class SnippetChooserModel(models.Model): advert = models.ForeignKey(Advert, help_text='help text') @@ -461,3 +464,18 @@ class MTIChildPage(MTIBasePage): class AbstractPage(Page): class Meta: abstract = True + + +@register_setting +class TestSetting(BaseSetting): + title = models.CharField(max_length=100) + email = models.EmailField(max_length=50) + + +@register_setting(icon="tag") +class IconSetting(BaseSetting): + pass + + +class NotYetRegisteredSetting(BaseSetting): + pass