diff --git a/cof/settings/common.py b/cof/settings/common.py index 0437f5db..09d8422a 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -68,6 +68,7 @@ INSTALLED_APPS = [ 'autocomplete_light', 'captcha', 'django_cas_ng', + 'djangoformsetjs', 'bootstrapform', 'kfet', 'kfet.open', diff --git a/kfet/auth/models.py b/kfet/auth/models.py index c8648eda..3bceec2d 100644 --- a/kfet/auth/models.py +++ b/kfet/auth/models.py @@ -5,6 +5,8 @@ from django.db import models from django.utils.crypto import get_random_string from django.utils.translation import ugettext_lazy as _ +from kfet.cms.models import CmsPermissionManager + class GenericTeamTokenManager(models.Manager): @@ -52,6 +54,7 @@ class CorePermissionManager(models.Manager): class Permission(DjangoPermission): kfetcore = CorePermissionManager() + kfetcms = CmsPermissionManager() class Meta: proxy = True diff --git a/kfet/auth/views.py b/kfet/auth/views.py index 018e33db..1386b5d6 100644 --- a/kfet/auth/views.py +++ b/kfet/auth/views.py @@ -11,6 +11,8 @@ from django.utils.translation import ugettext_lazy as _ from django.views.generic import View from django.views.decorators.http import require_http_methods +from kfet.cms.views import get_kfetcms_group_formview_extra + from .forms import GroupForm from .models import GenericTeamToken, Group @@ -125,6 +127,8 @@ def get_group_formview_extras(): _group_formview_extras = [] # Register additional group forms below. + _group_formview_extras.append( + get_kfetcms_group_formview_extra()) return [extra.copy() for extra in _group_formview_extras] diff --git a/kfet/cms/forms.py b/kfet/cms/forms.py new file mode 100644 index 00000000..7b38db17 --- /dev/null +++ b/kfet/cms/forms.py @@ -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=_( + "Attention : 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 diff --git a/kfet/cms/management/commands/kfet_loadwagtail.py b/kfet/cms/management/commands/kfet_loadwagtail.py index 86b94d3e..169c278b 100644 --- a/kfet/cms/management/commands/kfet_loadwagtail.py +++ b/kfet/cms/management/commands/kfet_loadwagtail.py @@ -1,8 +1,14 @@ -from django.contrib.auth.models import Group from django.core.management import call_command 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): @@ -33,3 +39,169 @@ class Command(BaseCommand): # Par défaut, il s'agit d'une copie du site K-Fêt (17-05) 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 + ]) diff --git a/kfet/cms/models.py b/kfet/cms/models.py index 0dff183f..5ac956a2 100644 --- a/kfet/cms/models.py +++ b/kfet/cms/models.py @@ -11,7 +11,7 @@ from wagtail.wagtailimages.edit_handlers import ImageChooserPanel from wagtail.wagtailsnippets.blocks import SnippetChooserBlock from wagtail.wagtailsnippets.models import register_snippet -from kfet.cms.context_processors import get_articles +from .utils import get_page_model_names @register_snippet @@ -60,6 +60,7 @@ class MenuBlock(blocks.StaticBlock): template = 'kfetcms/block_menu.html' def get_context(self, *args, **kwargs): + from .context_processors import get_articles context = super().get_context(*args, **kwargs) context.update(get_articles()) return context @@ -172,3 +173,18 @@ class KFetPage(Page): page.seo_title = page.title 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()) + ) diff --git a/kfet/cms/templates/kfet/permissions/collection_member_permissions_form.html b/kfet/cms/templates/kfet/permissions/collection_member_permissions_form.html new file mode 100644 index 00000000..1286e780 --- /dev/null +++ b/kfet/cms/templates/kfet/permissions/collection_member_permissions_form.html @@ -0,0 +1,20 @@ +{% load widget_tweaks %} +{% load kfet_extras %} + +
+ {{ form.non_field_errors }} + {% if form.DELETE %}{% include "kfet/formset_form_actions.html" %}{% endif %} +
+ {{ form.collection|add_class:"form-control input-sm" }} +
+ {% for option in form.permissions %} +
+ {% with p_type=formset.permission_types|get:forloop.counter0 %} + {# p_type format: (identifier, short_label, long_label) #} + + {% endwith %} +
+ {% endfor %} +
diff --git a/kfet/cms/templates/kfet/permissions/collection_member_permissions_formset.html b/kfet/cms/templates/kfet/permissions/collection_member_permissions_formset.html new file mode 100644 index 00000000..c5faf2af --- /dev/null +++ b/kfet/cms/templates/kfet/permissions/collection_member_permissions_formset.html @@ -0,0 +1,42 @@ +{% load i18n %} +{% load formset_tags %} + +
+ +
+ + {{ model_name|title }} +
+ +{{ formset.management_form }} + +{% if formset.non_form_errors %} + {{ formset.non_form_errors }} +{% endif %} + +{% with form_tpl="kfet/permissions/collection_member_permissions_form.html" %} + +
+ {% for form in formset %} + {% include form_tpl with form=form formset=formset only %} + {% endfor %} +
+ + + +{% endwith %} + +
+ + diff --git a/kfet/cms/templates/kfet/permissions/page_permissions_form.html b/kfet/cms/templates/kfet/permissions/page_permissions_form.html new file mode 100644 index 00000000..6b778861 --- /dev/null +++ b/kfet/cms/templates/kfet/permissions/page_permissions_form.html @@ -0,0 +1,20 @@ +{% load widget_tweaks %} +{% load kfet_extras %} + +
+ {{ form.non_field_errors }} + {% if form.DELETE %}{% include "kfet/formset_form_actions.html" %}{% endif %} +
+ {{ form.page|add_class:"form-control input-sm" }} +
+ {% for option in form.permission_types %} +
+ {% with p_type=formset.permission_types|get:forloop.counter0 %} + {# p_type format: (identifier, short_label, long_label) #} + + {% endwith %} +
+ {% endfor %} +
diff --git a/kfet/cms/templates/kfet/permissions/page_permissions_formset.html b/kfet/cms/templates/kfet/permissions/page_permissions_formset.html new file mode 100644 index 00000000..03770491 --- /dev/null +++ b/kfet/cms/templates/kfet/permissions/page_permissions_formset.html @@ -0,0 +1,42 @@ +{% load i18n %} +{% load formset_tags %} + +
+ +
+ + {% trans "Pages" %} +
+ +{{ formset.management_form }} + +{% if formset.non_form_errors %} + {{ formset.non_form_errors }} +{% endif %} + +{% with form_tpl="kfet/permissions/page_permissions_form.html" %} + +
+ {% for form in formset %} + {% include form_tpl with form=form formset=formset only %} + {% endfor %} +
+ + + +{% endwith %} + +
+ + diff --git a/kfet/cms/utils.py b/kfet/cms/utils.py new file mode 100644 index 00000000..21c44547 --- /dev/null +++ b/kfet/cms/utils.py @@ -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) + ] diff --git a/kfet/cms/views.py b/kfet/cms/views.py new file mode 100644 index 00000000..ae50c0f6 --- /dev/null +++ b/kfet/cms/views.py @@ -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 diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index a211cb5e..bf9bf629 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -4,6 +4,7 @@ /* Libs customizations */ @import url("libs/jconfirm-kfet.css"); @import url("libs/multiple-select-kfet.css"); +@import url("libs/formset-kfet.css"); /* Base */ @import url("base/misc.css"); diff --git a/kfet/static/kfet/css/libs/formset-kfet.css b/kfet/static/kfet/css/libs/formset-kfet.css new file mode 100644 index 00000000..d3a0463d --- /dev/null +++ b/kfet/static/kfet/css/libs/formset-kfet.css @@ -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; +} diff --git a/kfet/templates/kfet/base.html b/kfet/templates/kfet/base.html index 8f90f459..77733a07 100644 --- a/kfet/templates/kfet/base.html +++ b/kfet/templates/kfet/base.html @@ -24,6 +24,7 @@ + diff --git a/kfet/templates/kfet/formset_form_actions.html b/kfet/templates/kfet/formset_form_actions.html new file mode 100644 index 00000000..01bafaee --- /dev/null +++ b/kfet/templates/kfet/formset_form_actions.html @@ -0,0 +1,12 @@ +{% load i18n %} +{% load widget_tweaks %} + +
+ {{ form.DELETE|add_class:"hide" }} + + +
diff --git a/kfet/templatetags/dictionary_extras.py b/kfet/templatetags/dictionary_extras.py deleted file mode 100644 index fafaad8d..00000000 --- a/kfet/templatetags/dictionary_extras.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.template.defaulttags import register - -@register.filter -def get_item(dictionary, key): - return dictionary.get(key) diff --git a/kfet/templatetags/kfet_extras.py b/kfet/templatetags/kfet_extras.py new file mode 100644 index 00000000..79c6b481 --- /dev/null +++ b/kfet/templatetags/kfet_extras.py @@ -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)] diff --git a/requirements.txt b/requirements.txt index f3964212..4e8e5cab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,5 +26,9 @@ python-dateutil wagtail==1.10.* 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 wheel