from itertools import groupby from django import forms from django.conf import settings from django.contrib.auth.models import Group, Permission from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Min from django.template.loader import render_to_string from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy from wagtail.models import ( Collection, CollectionViewRestriction, GroupCollectionPermission, ) from .view_restrictions import BaseViewRestrictionForm class CollectionViewRestrictionForm(BaseViewRestrictionForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not getattr(settings, "WAGTAILDOCS_PRIVATE_COLLECTION_OPTIONS", {}).get( "SHARED_PASSWORD", True, ): self.fields["restriction_type"].choices = [ choice for choice in CollectionViewRestriction.RESTRICTION_CHOICES if choice[0] != CollectionViewRestriction.PASSWORD ] del self.fields["password"] class Meta: model = CollectionViewRestriction fields = ("restriction_type", "password", "groups") class SelectWithDisabledOptions(forms.Select): """ Subclass of Django's select widget that allows disabling options. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.disabled_values = () def create_option(self, name, value, *args, **kwargs): option_dict = super().create_option(name, value, *args, **kwargs) if value in self.disabled_values: option_dict["attrs"]["disabled"] = "disabled" return option_dict class CollectionChoiceField(forms.ModelChoiceField): widget = SelectWithDisabledOptions def __init__(self, *args, disabled_queryset=None, **kwargs): super().__init__(*args, **kwargs) self._indentation_start_depth = 2 self.disabled_queryset = disabled_queryset def _get_disabled_queryset(self): return self._disabled_queryset def _set_disabled_queryset(self, queryset): self._disabled_queryset = queryset if queryset is None: self.widget.disabled_values = () else: self.widget.disabled_values = queryset.values_list( self.to_field_name or "pk", flat=True ) disabled_queryset = property(_get_disabled_queryset, _set_disabled_queryset) def _set_queryset(self, queryset): min_depth = self.queryset.aggregate(Min("depth"))["depth__min"] if min_depth is None: self._indentation_start_depth = 2 else: self._indentation_start_depth = min_depth + 1 def label_from_instance(self, obj): return obj.get_indented_name(self._indentation_start_depth, html=True) class CollectionForm(forms.ModelForm): parent = CollectionChoiceField( label=gettext_lazy("Parent"), queryset=Collection.objects.all(), required=True, help_text=gettext_lazy( "Select hierarchical position. Note: a collection cannot become a child of itself or one of its " "descendants." ), ) class Meta: model = Collection fields = ("name",) def clean_parent(self): """ Our rules about where a user may add or move a collection are as follows: 1. The user must have 'add' permission on the parent collection (or its ancestors) 2. We are not moving a collection used to assign permissions for this user 3. We are not trying to move a collection to be parented by one of their descendants The first 2 items are taken care in the Create and Edit views by deleting the 'parent' field from the edit form if the user cannot move the collection. This causes Django's form machinery to ignore the parent field for parent regardless of what the user submits. This methods enforces rule #3 when we are editing an existing collection. """ parent = self.cleaned_data["parent"] if not self.instance._state.adding and not parent.pk == self.initial.get( "parent" ): old_descendants = list( self.instance.get_descendants(inclusive=True).values_list( "pk", flat=True ) ) if parent.pk in old_descendants: raise ValidationError(gettext_lazy("Please select another parent")) return parent class BaseCollectionMemberForm(forms.ModelForm): """ Abstract form handler for editing models that belong to a collection, such as documents and images. These forms are (optionally) instantiated with a 'user' kwarg, and take care of populating the 'collection' field's choices with the collections the user has permission for, as well as hiding the field when only one collection is available. Subclasses must define a 'permission_policy' attribute. """ def __init__(self, *args, **kwargs): user = kwargs.pop("user", None) super().__init__(*args, **kwargs) if user is None: self.collections = Collection.objects.all() else: self.collections = ( self.permission_policy.collections_user_has_permission_for(user, "add") ) if self.instance.pk: # editing an existing document; ensure that the list of available collections # includes its current collection self.collections = self.collections | Collection.objects.filter( id=self.instance.collection_id ) if len(self.collections) == 0: raise Exception( "Cannot construct %s for a user with no collection permissions" % type(self) ) elif len(self.collections) == 1: # don't show collection field if only one collection is available del self.fields["collection"] else: self.fields["collection"].queryset = self.collections def save(self, commit=True): if len(self.collections) == 1: # populate the instance's collection field with the one available collection self.instance.collection = self.collections[0] return super().save(commit=commit) class BaseGroupCollectionMemberPermissionFormSet(forms.BaseFormSet): """ A base formset class for managing GroupCollectionPermissions for a model with CollectionMember behaviour. Subclasses should provide attributes: permission_types - a list of (codename, short_label, long_label) tuples for the permissions being managed here permission_queryset - a queryset of Permission objects for the above permissions default_prefix - prefix to use on form fields if one is not specified in __init__ template = template filename """ def __init__(self, data=None, files=None, instance=None, prefix=None): if prefix is None: prefix = self.default_prefix if instance is None: instance = Group() if instance.pk is None: full_collection_permissions = [] else: full_collection_permissions = ( instance.collection_permissions.filter( permission__in=self.permission_queryset ) .select_related("permission__content_type", "collection") .order_by("collection") ) self.instance = instance initial_data = [] for collection, collection_permissions in groupby( full_collection_permissions, lambda cp: cp.collection, ): initial_data.append( { "collection": collection, "permissions": [cp.permission for cp in collection_permissions], } ) super().__init__(data, files, initial=initial_data, prefix=prefix) for form in self.forms: form.fields["DELETE"].widget = forms.HiddenInput() @property def empty_form(self): empty_form = super().empty_form empty_form.fields["DELETE"].widget = forms.HiddenInput() return empty_form def clean(self): """Checks that no two forms refer to the same collection object""" if any(self.errors): # Don't bother validating the formset unless each form is valid on its own return collections = [ form.cleaned_data["collection"] for form in self.forms # need to check for presence of 'collection' in cleaned_data, # because a completely blank form passes validation if form not in self.deleted_forms and "collection" in form.cleaned_data ] if len(set(collections)) != len(collections): # collections list contains duplicates raise forms.ValidationError( _( "You cannot have multiple permission records for the same collection." ) ) @transaction.atomic def save(self): if self.instance.pk is None: raise Exception( "Cannot save a GroupCollectionMemberPermissionFormSet " "for an unsaved group instance" ) # get a set of (collection, permission) tuples for all ticked permissions forms_to_save = [ form for form in self.forms if form not in self.deleted_forms and "collection" in form.cleaned_data ] final_permission_records = set() for form in forms_to_save: for permission in form.cleaned_data["permissions"]: final_permission_records.add( (form.cleaned_data["collection"], permission) ) # fetch the group's existing collection permission records for this model, # and from that, build a list of records to be created / deleted permission_ids_to_delete = [] permission_records_to_keep = set() for cp in self.instance.collection_permissions.filter( permission__in=self.permission_queryset, ): if (cp.collection, cp.permission) in final_permission_records: permission_records_to_keep.add((cp.collection, cp.permission)) else: permission_ids_to_delete.append(cp.id) self.instance.collection_permissions.filter( id__in=permission_ids_to_delete ).delete() permissions_to_add = final_permission_records - permission_records_to_keep GroupCollectionPermission.objects.bulk_create( [ GroupCollectionPermission( group=self.instance, collection=collection, permission=permission ) for (collection, permission) in permissions_to_add ] ) def as_admin_panel(self): return render_to_string( self.template, {"formset": self}, ) def collection_member_permission_formset_factory( model, permission_types, template, default_prefix=None ): permission_queryset = Permission.objects.filter( content_type__app_label=model._meta.app_label, codename__in=[ codename for codename, short_label, long_label in permission_types ], ).select_related("content_type") if default_prefix is None: default_prefix = "%s_permissions" % model._meta.model_name class PermissionMultipleChoiceField(forms.ModelMultipleChoiceField): """ Allows the custom labels from ``permission_types`` to be applied to permission checkboxes for the ``CollectionMemberPermissionsForm`` below """ def label_from_instance(self, obj): for codename, short_label, long_label in permission_types: if codename == obj.codename: return long_label return str(obj) class CollectionMemberPermissionsForm(forms.Form): """ For a given model with CollectionMember behaviour, defines the permissions that are assigned to an entity (such as a group or user) for a specific collection """ collection = CollectionChoiceField( label=_("Collection"), queryset=Collection.objects.all().prefetch_related("group_permissions"), empty_label=None, ) permissions = PermissionMultipleChoiceField( queryset=permission_queryset, required=False, widget=forms.CheckboxSelectMultiple, ) GroupCollectionMemberPermissionFormSet = type( "GroupCollectionMemberPermissionFormSet", (BaseGroupCollectionMemberPermissionFormSet,), { "permission_types": permission_types, "permission_queryset": permission_queryset, "default_prefix": default_prefix, "template": template, }, ) return forms.formset_factory( CollectionMemberPermissionsForm, formset=GroupCollectionMemberPermissionFormSet, extra=0, can_delete=True, ) GroupCollectionManagementPermissionFormSet = ( collection_member_permission_formset_factory( Collection, [ ("add_collection", _("Add"), _("Add collections")), ("change_collection", _("Edit"), _("Edit collections")), ("delete_collection", _("Delete"), _("Delete collections")), ], "wagtailadmin/permissions/includes/collection_management_permissions_form.html", ) )