CMS permissions can be managed from group views.

These permissions concern pages, images, documents and access to the
wagtail admin site. Only appropriate elements can be selected: only the
kfet root page and its descendants, same for the kfet root collection
(for images and documents), and kfet snippets (MemberTeam).

Add django-formset-js as dependency to help manipulate formsets.

K-Fêt groups created from "devdata" commands get suitable permissions
for the CMS.
This commit is contained in:
Aurélien Delobelle 2017-10-16 00:07:30 +02:00
parent 82582866b4
commit 07f1a53532
19 changed files with 687 additions and 8 deletions

View file

@ -68,6 +68,7 @@ INSTALLED_APPS = [
'autocomplete_light', 'autocomplete_light',
'captcha', 'captcha',
'django_cas_ng', 'django_cas_ng',
'djangoformsetjs',
'bootstrapform', 'bootstrapform',
'kfet', 'kfet',
'kfet.open', 'kfet.open',

View file

@ -5,6 +5,8 @@ from django.db import models
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from kfet.cms.models import CmsPermissionManager
class GenericTeamTokenManager(models.Manager): class GenericTeamTokenManager(models.Manager):
@ -52,6 +54,7 @@ class CorePermissionManager(models.Manager):
class Permission(DjangoPermission): class Permission(DjangoPermission):
kfetcore = CorePermissionManager() kfetcore = CorePermissionManager()
kfetcms = CmsPermissionManager()
class Meta: class Meta:
proxy = True proxy = True

View file

@ -11,6 +11,8 @@ from django.utils.translation import ugettext_lazy as _
from django.views.generic import View from django.views.generic import View
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from kfet.cms.views import get_kfetcms_group_formview_extra
from .forms import GroupForm from .forms import GroupForm
from .models import GenericTeamToken, Group from .models import GenericTeamToken, Group
@ -125,6 +127,8 @@ def get_group_formview_extras():
_group_formview_extras = [] _group_formview_extras = []
# Register additional group forms below. # Register additional group forms below.
_group_formview_extras.append(
get_kfetcms_group_formview_extra())
return [extra.copy() for extra in _group_formview_extras] return [extra.copy() for extra in _group_formview_extras]

194
kfet/cms/forms.py Normal file
View file

@ -0,0 +1,194 @@
from django import forms
from django.contrib.auth.models import Group
from django.template.loader import render_to_string
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailusers.forms import GroupPagePermissionFormSet
from utils.forms import KeepUnselectableModelFormMixin
from kfet.auth.fields import BasePermissionsField
from kfet.auth.models import Permission
class CmsGroupForm(KeepUnselectableModelFormMixin, forms.ModelForm):
"""
Allow to change permissions related to the cms of a `Group` object.
"""
access_admin = forms.BooleanField(
label=_("Accès à l'administration"),
required=False,
help_text=_(
"<b>Attention :</b> Aucun paramétrage effectué dans cette section "
"n'aura d'effet si cette case est décochée."
),
)
class Meta:
model = Group
fields = ()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
group = self.instance
if group.pk is not None:
self.fields['access_admin'].initial = (
group.permissions.filter(pk=self.access_admin_perm.pk).exists()
)
def save(self):
group = super().save()
if self.cleaned_data['access_admin']:
group.permissions.add(self.access_admin_perm)
else:
group.permissions.remove(self.access_admin_perm)
return group
@cached_property
def access_admin_perm(self):
return Permission.objects.get(
content_type__app_label='wagtailadmin',
codename='access_admin',
)
class SnippetsCmsGroupForm(KeepUnselectableModelFormMixin, forms.ModelForm):
permissions = BasePermissionsField(
label='',
queryset=Permission.kfetcms.all(),
)
class Meta:
model = Group
fields = ('permissions',)
def prepare_page_permissions_formset(pages):
"""
Create a new formset from base `GroupPagePermissionFormSet` Wagtail
formset.
- The choices of `pages` field of forms are limited to `pages`.
- If there is no initial data, add an initial with the first collection
selected.
- Make 'as_admin_panel' use a custom template.
Arguments
pages (iterable of `Page`)
Returns
A formset to select group permissions of pages.
"""
class RestrictedFormSet(GroupPagePermissionFormSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Remove forms cache, as theirs options are changed.
del self.forms
# 'initial' must be setup before accessing 'forms'.
self.initial = list(
filter(lambda i: i['page'] in pages, self.initial))
# This is just little kindness.
if not self.initial and pages:
self.initial = [{'page': pages[0]}]
for form in self.forms:
self.customize_form(form)
@property
def empty_form(self):
form = super().empty_form
self.customize_form(form)
return form
def customize_form(self, form):
# Widget must be setup before queryset.
form.fields['page'].widget = forms.Select()
form.fields['page'].queryset = pages
# Force use of `CheckboxInput` for `DELETE` field, as `HiddenInput`
# is not compatible with `django-formset-js`.
form.fields['DELETE'].widget = forms.CheckboxInput()
def as_admin_panel(self):
# http://docs.wagtail.io/en/latest/reference/hooks.html#register-group-permission-panel
template = 'kfet/permissions/page_permissions_formset.html'
return render_to_string(template, {'formset': self})
return RestrictedFormSet
def prepare_collection_member_permissions_formset(formset_cls, collections):
"""
Create a new formset from base `formset_cls`.
- The choices of `collections` field of forms produced by `formset_cls` are
limited to `collections`.
- If there is no initial data, add an initial with the first collection
selected.
- Make 'as_admin_panel' use a custom template.
Arguments
formset_cls (subclass of
`wagtail.wagtailcore.forms`
`.BaseGroupCollectionMemberPermissionFormSet`
):
Formset to select group permissions of a collection member model.
It should be rerieved from the `group_permission_panel` Wagtail
hook. This includes the `Document` and `Image` models of Wagtail.
collections (iterable of `wagtail.wagtailcore.models.Collection`)
Returns
A formset to select group permissions of collection-related models.
"""
class RestrictedFormSet(formset_cls):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Remove forms cache, as theirs options are changed.
del self.forms
# 'initial' must be setup before accessing 'forms'.
self.initial = list(
filter(lambda i: i['collection'] in collections, self.initial))
# This is just little kindness.
if not self.initial and collections:
self.initial = [{'collection': collections[0]}]
for form in self.forms:
self.customize_form(form)
@property
def empty_form(self):
form = super().empty_form
self.customize_form(form)
return form
def customize_form(self, form):
form.fields['collection'].queryset = collections
# Force use of `CheckboxInput` for `DELETE` field, as `HiddenInput`
# is not compatible with `django-formset-js`.
form.fields['DELETE'].widget = forms.CheckboxInput()
def as_admin_panel(self):
template = (
'kfet/permissions/collection_member_permissions_formset.html')
model = self.permission_queryset[0].content_type.model_class()
return render_to_string(template, {
'formset': self,
'model_name': model._meta.verbose_name_plural,
})
return RestrictedFormSet

View file

@ -1,8 +1,14 @@
from django.contrib.auth.models import Group
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import Q
from wagtail.wagtailcore.models import Page, Site from wagtail.wagtailcore.models import (
GroupCollectionPermission, GroupPagePermission, Page, Site,
)
from kfet.models import Group, Permission
from ...utils import get_kfet_root_collection, get_kfet_root_page
class Command(BaseCommand): class Command(BaseCommand):
@ -33,3 +39,169 @@ class Command(BaseCommand):
# Par défaut, il s'agit d'une copie du site K-Fêt (17-05) # Par défaut, il s'agit d'une copie du site K-Fêt (17-05)
call_command('loaddata', options['file']) call_command('loaddata', options['file'])
# Si les groupes K-Fêt existent, certaines permissions du CMS leur sont
# données.
try:
group_chef = Group.objects.get(name='K-Fêt César')
except Group.DoesNotExist:
pass
else:
self.add_admin_access(group_chef)
try:
group_boy = Group.objects.get(name='K-Fêt Légionnaire')
except Group.DoesNotExist:
pass
else:
self.add_staff_access(group_boy)
def add_admin_access(self, group):
"""
Add all cms-related permissions to `group`.
Explicitly, permissions added are:
- access admin of Wagtail,
- all permissions for the kfet root page (by inheritance, this applies
to all its descendants),
- all permissions on the MemberTeam snippet,
- add/change documents,
- add/change images.
To avoid bugs related to permissions not added by this method, it is
guaranteed the group has more or the same permissions than at the
beginning.
"""
group.permissions.add(
Permission.objects.get(
content_type__app_label='wagtailadmin',
codename='access_admin',
),
# Snippets permissions
*Permission.kfetcms.all(),
)
# Page permissions: set all for the kfet root page.
root_page = get_kfet_root_page()
p_types = ('add', 'edit', 'publish', 'bulk_delete', 'lock')
GroupPagePermission.objects.filter(
group=group, page=root_page,
permission_type__in=p_types,
).delete()
GroupPagePermission.objects.bulk_create([
GroupPagePermission(
group=group, page=root_page,
permission_type=p_type,
)
for p_type in p_types
])
# Collection-based permissions: set all for the kfet root collection
# for each known collection-based model (docs, images).
root_collection = get_kfet_root_collection()
collection_perms = Permission.objects.filter(
Q(
content_type__app_label='wagtaildocs',
codename__in=['add_document', 'change_document'],
) |
Q(
content_type__app_label='wagtailimages',
codename__in=['add_image', 'change_image'],
)
)
GroupCollectionPermission.objects.filter(
group=group, collection=root_collection,
permission__in=collection_perms,
).delete()
GroupCollectionPermission.objects.bulk_create([
GroupCollectionPermission(
group=group, collection=root_collection,
permission=perm,
)
for perm in collection_perms
])
def add_staff_access(self, group):
"""
Add a subset of cms-related permissions to `group`.
Permissions added are:
- access admin of Wagtail,
- add/edit permissions for the kfet root page (by inheritance, this
applies to all its descendants),
- all permissions on the MemberTeam snippet,
- add/change own documents,
- add/change own images.
Because 'publish' page permission type is not given, group members can
only create or change pages as drafts.
To avoid bugs related to permissions not added by this method, it is
guaranteed the group has more or the same permissions than at the
beginning.
"""
group.permissions.add(
Permission.objects.get(
content_type__app_label='wagtailadmin',
codename='access_admin',
),
*Permission.kfetcms.filter(codename__in=[
'add_memberteam',
]),
)
# Give 'safe' operations permissions for the kfet root page.
root_page = get_kfet_root_page()
p_types = ('add', 'edit')
GroupPagePermission.objects.filter(
group=group, page=root_page,
permission_type__in=p_types,
).delete()
GroupPagePermission.objects.bulk_create([
GroupPagePermission(
group=group, page=root_page,
permission_type=p_type,
)
for p_type in p_types
])
# Give 'safe' operations permissions for the collection-based models.
root_collection = get_kfet_root_collection()
collection_perms = Permission.objects.filter(
Q(
content_type__app_label='wagtaildocs',
codename__in=['add_document'],
) |
Q(
content_type__app_label='wagtailimages',
codename__in=['add_image'],
)
)
GroupCollectionPermission.objects.filter(
group=group, collection=root_collection,
permission__in=collection_perms,
).delete()
GroupCollectionPermission.objects.bulk_create([
GroupCollectionPermission(
group=group, collection=root_collection,
permission=perm,
)
for perm in collection_perms
])

View file

@ -11,7 +11,7 @@ from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtailsnippets.blocks import SnippetChooserBlock from wagtail.wagtailsnippets.blocks import SnippetChooserBlock
from wagtail.wagtailsnippets.models import register_snippet from wagtail.wagtailsnippets.models import register_snippet
from kfet.cms.context_processors import get_articles from .utils import get_page_model_names
@register_snippet @register_snippet
@ -60,6 +60,7 @@ class MenuBlock(blocks.StaticBlock):
template = 'kfetcms/block_menu.html' template = 'kfetcms/block_menu.html'
def get_context(self, *args, **kwargs): def get_context(self, *args, **kwargs):
from .context_processors import get_articles
context = super().get_context(*args, **kwargs) context = super().get_context(*args, **kwargs)
context.update(get_articles()) context.update(get_articles())
return context return context
@ -172,3 +173,18 @@ class KFetPage(Page):
page.seo_title = page.title page.seo_title = page.title
return context return context
##
# Helpers for kfetauth app
##
class CmsPermissionManager(models.Manager):
def get_queryset(self):
return (
super().get_queryset()
.filter(content_type__app_label='kfetcms')
# Permissions of Page-based models are unused.
.exclude(content_type__model__in=get_page_model_names())
)

View file

@ -0,0 +1,20 @@
{% load widget_tweaks %}
{% load kfet_extras %}
<div class="form-inline" data-formset-form>
{{ form.non_field_errors }}
{% if form.DELETE %}{% include "kfet/formset_form_actions.html" %}{% endif %}
<div class="form-group">
{{ form.collection|add_class:"form-control input-sm" }}
</div>
{% for option in form.permissions %}
<div class="checkbox">
{% with p_type=formset.permission_types|get:forloop.counter0 %}
{# p_type format: (identifier, short_label, long_label) #}
<label title="{{ p_type.2 }}">
{{ option.tag }} {{ p_type.1}}
</label>
{% endwith %}
</div>
{% endfor %}
</div>

View file

@ -0,0 +1,42 @@
{% load i18n %}
{% load formset_tags %}
<div data-formset-prefix="{{ formset.prefix }}">
<div class="h5">
<button type="button" class="pull-right btn btn-default btn-sm" data-formset-add>
{% trans "Ajouter pour une autre collection" %}
</button>
<b>{{ model_name|title }}</b>
</div>
{{ formset.management_form }}
{% if formset.non_form_errors %}
<span class="help-block">{{ formset.non_form_errors }}</span>
{% endif %}
{% with form_tpl="kfet/permissions/collection_member_permissions_form.html" %}
<div data-formset-body>
{% for form in formset %}
{% include form_tpl with form=form formset=formset only %}
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
{% include form_tpl with form=formset.empty_form formset=formset only %}
{% endescapescript %}
</script>
{% endwith %}
</div>
<script type="text/javascript">
$( function() {
let $formset_container = $('[data-formset-prefix={{ formset.prefix }}]');
$formset_container.formset();
});
</script>

View file

@ -0,0 +1,20 @@
{% load widget_tweaks %}
{% load kfet_extras %}
<div class="form-inline" data-formset-form>
{{ form.non_field_errors }}
{% if form.DELETE %}{% include "kfet/formset_form_actions.html" %}{% endif %}
<div class="form-group">
{{ form.page|add_class:"form-control input-sm" }}
</div>
{% for option in form.permission_types %}
<div class="checkbox">
{% with p_type=formset.permission_types|get:forloop.counter0 %}
{# p_type format: (identifier, short_label, long_label) #}
<label title="{{ p_type.2 }}">
{{ option.tag }} {{ p_type.1 }}
</label>
{% endwith %}
</div>
{% endfor %}
</div>

View file

@ -0,0 +1,42 @@
{% load i18n %}
{% load formset_tags %}
<div data-formset-prefix="{{ formset.prefix }}">
<div class="h5">
<button type="button" class="pull-right btn btn-default btn-sm" data-formset-add>
{% trans "Ajouter pour une autre page" %}
</button>
<b>{% trans "Pages" %}</b>
</div>
{{ formset.management_form }}
{% if formset.non_form_errors %}
<span class="help-block">{{ formset.non_form_errors }}</span>
{% endif %}
{% with form_tpl="kfet/permissions/page_permissions_form.html" %}
<div data-formset-body>
{% for form in formset %}
{% include form_tpl with form=form formset=formset only %}
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
{% include form_tpl with form=formset.empty_form formset=formset only %}
{% endescapescript %}
</script>
{% endwith %}
</div>
<script type="text/javascript">
$( function() {
let $formset_container = $('[data-formset-prefix={{ formset.prefix }}]');
$formset_container.formset();
});
</script>

35
kfet/cms/utils.py Normal file
View file

@ -0,0 +1,35 @@
from django.apps import apps
from wagtail.wagtailcore.models import Collection, Page
def get_kfet_root_page():
"""
Returns the K-Fêt root page, or 'None' if it does not exist.
"""
from .models import KFetPage
return KFetPage.objects.first()
def get_kfet_root_collection():
"""
Returns the K-Fêt root collection, or 'None' if it does not exist.
"""
return Collection.objects.filter(name='K-Fêt').first()
def get_page_model_names():
"""
Returns all model names of `Page` subclasses.
This uses the django apps registry (instead of `ContentType.objects.all()`)
in order to be usuable even before migrations are applied. E.g. this can be
used in `Field.__init__`.
Note these model names are the same in `model` attribute of `ContentType`
objects.
"""
return [
model._meta.model_name
for model in apps.get_models() if issubclass(model, Page)
]

73
kfet/cms/views.py Normal file
View file

@ -0,0 +1,73 @@
from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailadmin.forms import (
BaseGroupCollectionMemberPermissionFormSet,
)
from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.models import Collection, Page
from .forms import (
CmsGroupForm, SnippetsCmsGroupForm, prepare_page_permissions_formset,
prepare_collection_member_permissions_formset,
)
from .utils import get_kfet_root_collection, get_kfet_root_page
def get_kfetcms_group_formview_extra():
forms = []
# Misc cms-related permissions.
forms.append((CmsGroupForm, {'prefix': 'kfetcms'}))
# Snippets permissions.
forms.append((SnippetsCmsGroupForm, {'prefix': 'kfetcms-snippets'}))
# Setup PagePermissionsFormSet for kfet root page descendants only.
root_page = get_kfet_root_page()
if root_page:
pages = Page.objects.descendant_of(root_page, inclusive=True)
forms.append(
(prepare_page_permissions_formset(pages=pages), {})
)
# Setup CollectionPermissions for kfet root collection descendants only.
root_collection = get_kfet_root_collection()
if root_collection:
collections = Collection.objects.descendant_of(
root_collection, inclusive=True)
# Retrieve forms based on CollectionMemberPermissionFormSet displayed
# by Wagtail admin site.
# http://docs.wagtail.io/en/stable/reference/hooks.html#register-group-permission-panel
collectionmember_form_classes = []
for fn in hooks.get_hooks('register_group_permission_panel'):
form_cls = fn()
if issubclass(
form_cls,
BaseGroupCollectionMemberPermissionFormSet
):
collectionmember_form_classes.append(form_cls)
# Apply choices limit.
for form_cls in collectionmember_form_classes:
forms.append((
prepare_collection_member_permissions_formset(
form_cls, collections=collections),
{},
))
# The 'extra' definition of kfetcms.
extra = {
'title': _("Site"),
'description': _(
"Permissions liées aux pages à contenu libre."
),
'form_classes': forms,
}
return extra

View file

@ -4,6 +4,7 @@
/* Libs customizations */ /* Libs customizations */
@import url("libs/jconfirm-kfet.css"); @import url("libs/jconfirm-kfet.css");
@import url("libs/multiple-select-kfet.css"); @import url("libs/multiple-select-kfet.css");
@import url("libs/formset-kfet.css");
/* Base */ /* Base */
@import url("base/misc.css"); @import url("base/misc.css");

View file

@ -0,0 +1,36 @@
[data-formset-form] {
margin-bottom: 5px;
padding: 10px;
border: 1px solid #CCC;
border-radius: 3px;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
transition: border 0.3s, color 0.3s;
}
@media (min-width: 768px) {
[data-formset-form] > :not(:last-child) {
margin-right: 15px;
}
[data-formset-form] .form-actions {
float: right;
margin-left: 15px;
margin-right: 0;
}
}
[data-formset-add] {
position: relative;
top: -5px;
right: 0;
}
[data-formset-form-deleted] {
background: repeating-linear-gradient(
45deg,
#e9afb9, #e9afb9 2px, transparent 2px, transparent 12px
);
border: 1px solid #e9afb9;
color: #999;
}

View file

@ -24,6 +24,7 @@
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script type="text/javascript" src="{% static 'kfet/js/jquery-ui.min.js' %}"></script> <script type="text/javascript" src="{% static 'kfet/js/jquery-ui.min.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/jquery-confirm.js' %}"></script> <script type="text/javascript" src="{% static 'kfet/js/jquery-confirm.js' %}"></script>
<script type="text/javascript" src="{% static 'js/jquery.formset.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/reconnecting-websocket.js' %}"></script> <script type="text/javascript" src="{% static 'kfet/js/reconnecting-websocket.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/moment.js' %}"></script> <script type="text/javascript" src="{% static 'kfet/js/moment.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/moment-fr.js' %}"></script> <script type="text/javascript" src="{% static 'kfet/js/moment-fr.js' %}"></script>

View file

@ -0,0 +1,12 @@
{% load i18n %}
{% load widget_tweaks %}
<div class="form-group form-actions">
{{ form.DELETE|add_class:"hide" }}
<button type="button" class="btn btn-danger btn-sm" title="{% trans "Supprimer" %}" data-formset-delete-button>
<span class="glyphicon glyphicon-trash"></span>
</button>
<button type="button" class="btn btn-default btn-sm" title="{% trans "Restaurer" %}" data-formset-restore-button style="display: none">
<span class="glyphicon glyphicon-plus"></span>
</button>
</div>

View file

@ -1,5 +0,0 @@
from django.template.defaulttags import register
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)

View file

@ -0,0 +1,8 @@
from django.template.defaulttags import register
@register.filter
def get(o, key):
if hasattr(o, 'get'):
return o.get(key)
return o[int(key)]

View file

@ -26,5 +26,9 @@ python-dateutil
wagtail==1.10.* wagtail==1.10.*
wagtailmenus==2.2.* wagtailmenus==2.2.*
# This fork enables restore of forms.
# Original project: https://bitbucket.org/tim_heap/django-formset-js
git+https://bitbucket.org/georgema1982/django-formset-js.git#egg=django-formset-js
# Production tools # Production tools
wheel wheel