Преглед на файлове

Implement nested collections

Robert Rollins преди 4 години
родител
ревизия
e404f83cd1

+ 69 - 2
wagtail/admin/forms/collections.py

@@ -3,6 +3,7 @@ from itertools import groupby
 from django import forms
 from django.contrib.auth.models import Group, Permission
 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 _
 
@@ -18,11 +19,76 @@ class CollectionViewRestrictionForm(BaseViewRestrictionForm):
         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(
+        queryset=Collection.objects.all(),
+        required=False,
+        help_text=_(
+            "Select hierarchical position. Note: a collection cannot become a child of itself or one of its "
+            "descendants."
+        )
+    )
+
     class Meta:
         model = Collection
         fields = ('name',)
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if self.instance._state.adding:
+            self.initial['parent'] = Collection.get_first_root_node().pk
+        else:
+            self.initial['parent'] = self.instance.get_parent().pk
+            self.fields['parent'].disabled_queryset = self.instance.get_descendants(inclusive=True)
+
 
 class BaseCollectionMemberForm(forms.ModelForm):
     """
@@ -212,8 +278,9 @@ def collection_member_permission_formset_factory(
         defines the permissions that are assigned to an entity
         (i.e. group or user) for a specific collection
         """
-        collection = forms.ModelChoiceField(
-            queryset=Collection.objects.all().prefetch_related('group_permissions')
+        collection = CollectionChoiceField(
+            queryset=Collection.objects.all().prefetch_related('group_permissions'),
+            empty_label=None
         )
         permissions = PermissionMultipleChoiceField(
             queryset=permission_queryset,

+ 5 - 2
wagtail/admin/templates/wagtailadmin/collections/index.html

@@ -1,5 +1,5 @@
 {% extends "wagtailadmin/generic/index.html" %}
-{% load i18n %}
+{% load i18n wagtailadmin_tags %}
 
 {% block listing %}
     <div class="nice-padding">
@@ -14,11 +14,14 @@
                         </tr>
                     </thead>
                     <tbody>
+                        {% minimum_collection_depth collections as min_depth %}
                         {% for collection in collections %}
                             <tr>
                                 <td class="title">
                                     <div class="title-wrapper">
-                                        <a href="{% url 'wagtailadmin_collections:edit' collection.id %}">{{ collection }}</a>
+                                        <a href="{% url 'wagtailadmin_collections:edit' collection.id %}">
+                                            {% format_collection collection min_depth %}
+                                        </a>
                                     </div>
                                 </td>
                             </tr>

+ 12 - 3
wagtail/admin/templates/wagtailadmin/shared/collection_chooser.html

@@ -1,5 +1,4 @@
-{% load i18n %}
-{% load l10n %}
+{% load i18n l10n wagtailadmin_tags %}
 
 <li>
     <div class="field choice_field select">
@@ -8,8 +7,18 @@
             <div class="input">
                 <select id="collection_chooser_collection_id" name="collection_id">
                     <option value="">{% trans "All collections" %}</option>
+                    {% minimum_collection_depth collections as min_depth %}
                     {% for collection in collections %}
-                        <option value="{{ collection.id|unlocalize }}" {% if collection == current_collection %}selected="selected"{% endif %}>{{ collection.name }}</option>
+                        <option value="{{ collection.id|unlocalize }}"
+                                {% if collection == current_collection %}selected="selected"{% endif %}>
+                            {% if request.user.is_superuser %}
+                                {# Superuser may see all collections #}
+                                {% format_collection collection %}
+                            {% else %}
+                                {# Pass the minimum depth of the permitted collections, since user isn't a superuser #}
+                                {% format_collection collection min_depth %}
+                            {% endif %}
+                        </option>
                     {% endfor %}
                 </select>
                 <span></span>

+ 26 - 1
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -10,6 +10,7 @@ from django.conf import settings
 from django.contrib.admin.utils import quote
 from django.contrib.humanize.templatetags.humanize import intcomma
 from django.contrib.messages.constants import DEFAULT_TAGS as MESSAGE_TAGS
+from django.db.models import Min, QuerySet
 from django.template.defaultfilters import stringfilter
 from django.template.loader import render_to_string
 from django.templatetags.static import static
@@ -27,7 +28,7 @@ from wagtail.admin.search import admin_search_areas
 from wagtail.admin.staticfiles import versioned_static as versioned_static_func
 from wagtail.core import hooks
 from wagtail.core.models import (
-    CollectionViewRestriction, Page, PageLogEntry, PageViewRestriction, UserPagePermissionsProxy)
+    Collection, CollectionViewRestriction, Page, PageLogEntry, PageViewRestriction, UserPagePermissionsProxy)
 from wagtail.core.utils import cautious_slugify as _cautious_slugify
 from wagtail.core.utils import accepts_kwarg, camelcase_to_underscore, escape_script
 from wagtail.users.utils import get_gravatar_url
@@ -603,3 +604,27 @@ def format_action_log_message(log_entry):
     if not isinstance(log_entry, PageLogEntry):
         return ''
     return log_action_registry.format_message(log_entry)
+
+
+@register.simple_tag
+def format_collection(coll: Collection, min_depth: int = 2) -> str:
+    """
+    Renders a given Collection's name as a formatted string that displays its
+    hierarchical depth via indentation. If min_depth is supplied, the
+    Collection's depth is rendered relative to that depth. min_depth defaults
+    to 2, the depth of the first non-Root Collection.
+
+    Example usage: {% format_collection collection min_depth %}
+    Example output: "&nbsp;&nbsp;&nbsp;&nbsp;&#x21b3 Child Collection"
+    """
+    return coll.get_indented_name(min_depth, html=True)
+
+
+@register.simple_tag
+def minimum_collection_depth(collections: QuerySet) -> int:
+    """
+    Returns the minimum depth of the Collections in the given queryset.
+    Call this before beginning a loop through Collections that will
+    use {% format_collection collection min_depth %}.
+    """
+    return collections.aggregate(Min('depth'))['depth__min'] or 2

+ 25 - 8
wagtail/admin/views/collections.py

@@ -21,8 +21,8 @@ class Index(IndexView):
     header_icon = 'folder-open-1'
 
     def get_queryset(self):
-        # Only return children of the root node, so that the root is not editable
-        return Collection.get_first_root_node().get_children().order_by('name')
+        # Only return descendants of the root node, so that the root is not editable
+        return Collection.get_first_root_node().get_descendants()
 
 
 class Create(CreateView):
@@ -36,10 +36,10 @@ class Create(CreateView):
     header_icon = 'folder-open-1'
 
     def save_instance(self):
-        # Always create new collections as children of root
         instance = self.form.save(commit=False)
-        root_collection = Collection.get_first_root_node()
-        root_collection.add_child(instance=instance)
+        parent_pk = self.form.data.get('parent')
+        parent = Collection.objects.get(pk=parent_pk) if parent_pk else Collection.get_first_root_node()
+        parent.add_child(instance=instance)
         return instance
 
 
@@ -57,9 +57,26 @@ class Edit(EditView):
     context_object_name = 'collection'
     header_icon = 'folder-open-1'
 
+    def save_instance(self):
+        instance = self.form.save()
+        parent_pk = self.form.data.get('parent')
+        if parent_pk and parent_pk != instance.get_parent().pk:
+            instance.move(Collection.objects.get(pk=parent_pk), 'sorted-child')
+        return instance
+
+    def form_valid(self, form):
+        new_parent_pk = int(form.data.get('parent', 0))
+        old_descendants = list(form.instance.get_descendants(
+            inclusive=True).values_list('pk', flat=True)
+        )
+        if new_parent_pk in old_descendants:
+            form.add_error('parent', gettext_lazy('Please select another parent'))
+            return self.form_invalid(form)
+        return super().form_valid(form)
+
     def get_queryset(self):
-        # Only return children of the root node, so that the root is not editable
-        return Collection.get_first_root_node().get_children().order_by('name')
+        # Only return descendants of the root node, so that the root is not editable
+        return Collection.get_first_root_node().get_descendants().order_by('path')
 
 
 class Delete(DeleteView):
@@ -74,7 +91,7 @@ class Delete(DeleteView):
 
     def get_queryset(self):
         # Only return children of the root node, so that the root is not editable
-        return Collection.get_first_root_node().get_children().order_by('name')
+        return Collection.get_first_root_node().get_descendants().order_by('path')
 
     def get_collection_contents(self):
         collection_contents = [

+ 32 - 8
wagtail/core/models.py

@@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
 from django.core.handlers.base import BaseHandler
 from django.core.handlers.wsgi import WSGIRequest
 from django.db import models, transaction
-from django.db.models import Case, Q, Value, When
+from django.db.models import Q, Value
 from django.db.models.expressions import OuterRef, Subquery
 from django.db.models.functions import Concat, Lower, Substr
 from django.http import Http404
@@ -25,7 +25,9 @@ from django.utils import timezone
 from django.utils.cache import patch_cache_control
 from django.utils.encoding import force_str
 from django.utils.functional import cached_property
+from django.utils.html import format_html
 from django.utils.module_loading import import_string
+from django.utils.safestring import mark_safe
 from django.utils.text import capfirst, slugify
 from django.utils.translation import gettext_lazy as _
 from modelcluster.fields import ParentalKey, ParentalManyToManyField
@@ -2738,6 +2740,8 @@ class Collection(TreebeardPathFixMixin, MP_Node):
     name = models.CharField(max_length=255, verbose_name=_('name'))
 
     objects = CollectionManager()
+    # Tell treebeard to order Collections' paths such that they are ordered by name at each level.
+    node_order_by = ['name']
 
     def __str__(self):
         return self.name
@@ -2761,13 +2765,33 @@ class Collection(TreebeardPathFixMixin, MP_Node):
         """Return a query set of all collection view restrictions that apply to this collection"""
         return CollectionViewRestriction.objects.filter(collection__in=self.get_ancestors(inclusive=True))
 
-    @staticmethod
-    def order_for_display(queryset):
-        return queryset.annotate(
-            display_order=Case(
-                When(depth=1, then=Value('')),
-                default='name')
-        ).order_by('display_order')
+    def get_indented_name(self, indentation_start_depth=2, html=False):
+        """
+        Renders this Collection's name as a formatted string that displays its hierarchical depth via indentation.
+        If indentation_start_depth is supplied, the Collection's depth is rendered relative to that depth.
+        indentation_start_depth defaults to 2, the depth of the first non-Root Collection.
+        Pass html=True to get a HTML representation, instead of the default plain-text.
+
+        Example text output: "    ↳ Pies"
+        Example HTML output: "&nbsp;&nbsp;&nbsp;&nbsp;&#x21b3 Pies"
+        """
+        display_depth = self.depth - indentation_start_depth
+        # A Collection with a display depth of 0 or less (Root's can be -1), should have no indent.
+        if display_depth <= 0:
+            return self.name
+
+        # Indent each level of depth by 4 spaces (the width of the ↳ character in our admin font), then add ↳
+        # before adding the name.
+        if html:
+            # NOTE: &#x21b3 is the hex HTML entity for ↳.
+            return format_html(
+                "{indent}{icon} {name}",
+                indent=mark_safe('&nbsp;' * 4 * display_depth),
+                icon=mark_safe('&#x21b3'),
+                name=self.name
+            )
+        # Output unicode plain-text version
+        return "{}↳ {}".format(' ' * 4 * display_depth, self.name)
 
     class Meta:
         verbose_name = _('collection')

+ 17 - 0
wagtail/core/wagtail_hooks.py

@@ -2,6 +2,7 @@ from django.conf import settings
 from django.contrib.auth.models import Permission
 from django.contrib.auth.views import redirect_to_login
 from django.urls import reverse
+from django.utils.translation import ngettext
 
 from wagtail.core import hooks
 from wagtail.core.models import PageViewRestriction
@@ -75,3 +76,19 @@ def register_task_permissions():
         content_type__app_label='wagtailcore',
         codename__in=['add_task', 'change_task', 'delete_task']
     )
+
+
+@hooks.register('describe_collection_contents')
+def describe_collection_children(collection):
+    descendant_count = collection.get_descendants().count()
+    if descendant_count:
+        url = reverse('wagtailadmin_collections:index')
+        return {
+            'count': descendant_count,
+            'count_text': ngettext(
+                "%(count)s descendant collection",
+                "%(count)s descendant collections",
+                descendant_count
+            ) % {'count': descendant_count},
+            'url': url,
+        }

+ 13 - 1
wagtail/documents/forms.py

@@ -4,11 +4,21 @@ from django.utils.translation import gettext_lazy as _
 
 from wagtail.admin import widgets
 from wagtail.admin.forms.collections import (
-    BaseCollectionMemberForm, collection_member_permission_formset_factory)
+    BaseCollectionMemberForm, CollectionChoiceField, collection_member_permission_formset_factory)
+from wagtail.core.models import Collection
 from wagtail.documents.models import Document
 from wagtail.documents.permissions import permission_policy as documents_permission_policy
 
 
+# Callback to allow us to override the default form field for the collection field
+def formfield_for_dbfield(db_field, **kwargs):
+    if db_field.name == 'collection':
+        return CollectionChoiceField(queryset=Collection.objects.all(), empty_label=None, **kwargs)
+
+    # For all other fields, just call its formfield() method.
+    return db_field.formfield(**kwargs)
+
+
 class BaseDocumentForm(BaseCollectionMemberForm):
     permission_policy = documents_permission_policy
 
@@ -26,6 +36,7 @@ def get_document_form(model):
         model,
         form=BaseDocumentForm,
         fields=fields,
+        formfield_callback=formfield_for_dbfield,
         widgets={
             'tags': widgets.AdminTagWidget,
             'file': forms.FileInput()
@@ -41,6 +52,7 @@ def get_document_multi_form(model):
         model,
         form=BaseDocumentForm,
         fields=fields,
+        formfield_callback=formfield_for_dbfield,
         widgets={
             'tags': widgets.AdminTagWidget,
             'file': forms.FileInput()

+ 14 - 8
wagtail/documents/templates/wagtaildocs/multiple/add.html

@@ -30,14 +30,20 @@
                     <div class="field choice_field select">
                         <label for="id_adddocument_collection">{% trans "Add to collection:" %}</label>
                         <div class="field-content">
-                            <div class="input">
-                                <select id="id_adddocument_collection" name="collection">
-                                    {% for collection in collections %}
-                                        <option value="{{ collection.id|unlocalize }}">{{ collection.name }}</option>
-                                    {% endfor %}
-                                </select>
-                                <span></span>
-                            </div>
+                            <select id="id_adddocument_collection" name="collection">
+                                {% minimum_collection_depth collections as min_depth %}
+                                {% for collection in collections %}
+                                    <option value="{{ collection.id|unlocalize }}">
+                                      {% if request.user.is_superuser %}
+                                          {# Superuser may see all collections. #}
+                                          {% format_collection collection %}
+                                      {% else %}
+                                          {# Pass the minimum depth of the permitted collections, since user isn't a superuser #}
+                                          {% format_collection collection min_depth %}
+                                      {% endif %}
+                                    </option>
+                                {% endfor %}
+                            </select>
                         </div>
                     </div>
                 {% endif %}

+ 0 - 2
wagtail/documents/views/chooser.py

@@ -93,8 +93,6 @@ def chooser(request):
         collections = Collection.objects.all()
         if len(collections) < 2:
             collections = None
-        else:
-            collections = Collection.order_for_display(collections)
 
         documents = documents.order_by('-created_at')
         documents_exist = documents.exists()

+ 0 - 2
wagtail/documents/views/documents.py

@@ -66,8 +66,6 @@ def index(request):
     )
     if len(collections) < 2:
         collections = None
-    else:
-        collections = Collection.order_for_display(collections)
 
     # Create response
     if request.is_ajax():

+ 3 - 6
wagtail/documents/views/multiple.py

@@ -8,7 +8,6 @@ from django.views.decorators.http import require_POST
 from django.views.decorators.vary import vary_on_headers
 
 from wagtail.admin.auth import PermissionPolicyChecker
-from wagtail.core.models import Collection
 from wagtail.search.backends import get_search_backends
 
 from .. import get_document_model
@@ -26,11 +25,9 @@ def add(request):
     DocumentMultiForm = get_document_multi_form(Document)
 
     collections = permission_policy.collections_user_has_permission_for(request.user, 'add')
-    if len(collections) > 1:
-        collections_to_choose = Collection.order_for_display(collections)
-    else:
+    if len(collections) < 2:
         # no need to show a collections chooser
-        collections_to_choose = None
+        collections = None
 
     if request.method == 'POST':
         if not request.is_ajax():
@@ -86,7 +83,7 @@ def add(request):
 
         return TemplateResponse(request, 'wagtaildocs/multiple/add.html', {
             'help_text': form.fields['file'].help_text,
-            'collections': collections_to_choose,
+            'collections': collections,
             'form_media': form.media,
         })
 

+ 5 - 2
wagtail/images/forms.py

@@ -5,18 +5,21 @@ from django.utils.translation import gettext as _
 
 from wagtail.admin import widgets
 from wagtail.admin.forms.collections import (
-    BaseCollectionMemberForm, collection_member_permission_formset_factory)
+    BaseCollectionMemberForm, CollectionChoiceField, collection_member_permission_formset_factory)
+from wagtail.core.models import Collection
 from wagtail.images.fields import WagtailImageField
 from wagtail.images.formats import get_image_formats
 from wagtail.images.models import Image
 from wagtail.images.permissions import permission_policy as images_permission_policy
 
 
-# Callback to allow us to override the default form field for the image file field
+# Callback to allow us to override the default form field for the image file field and collection field.
 def formfield_for_dbfield(db_field, **kwargs):
     # Check if this is the file field
     if db_field.name == 'file':
         return WagtailImageField(label=capfirst(db_field.verbose_name), **kwargs)
+    elif db_field.name == 'collection':
+        return CollectionChoiceField(queryset=Collection.objects.all(), empty_label=None, **kwargs)
 
     # For all other fields, just call its formfield() method.
     return db_field.formfield(**kwargs)

+ 14 - 8
wagtail/images/templates/wagtailimages/multiple/add.html

@@ -30,14 +30,20 @@
                     <div class="field choice_field select">
                         <label for="id_addimage_collection">{% trans "Add to collection:" %}</label>
                         <div class="field-content">
-                            <div class="input">
-                                <select id="id_addimage_collection" name="collection">
-                                    {% for collection in collections %}
-                                        <option value="{{ collection.id|unlocalize }}">{{ collection.name }}</option>
-                                    {% endfor %}
-                                </select>
-                                <span></span>
-                            </div>
+                            <select id="id_addimage_collection" name="collection">
+                                {% minimum_collection_depth collections as min_depth %}
+                                {% for collection in collections %}
+                                    <option value="{{ collection.id|unlocalize }}">
+                                      {% if request.user.is_superuser %}
+                                          {# Superuser may see all collections. #}
+                                          {% format_collection collection %}
+                                      {% else %}
+                                          {# Pass the minimum depth of the permitted collections, since user isn't a superuser #}
+                                          {% format_collection collection min_depth %}
+                                      {% endif %}
+                                    </option>
+                                {% endfor %}
+                            </select>
                         </div>
                     </div>
                 {% endif %}

+ 0 - 2
wagtail/images/views/chooser.py

@@ -57,8 +57,6 @@ def get_chooser_context(request):
     collections = Collection.objects.all()
     if len(collections) < 2:
         collections = None
-    else:
-        collections = Collection.order_for_display(collections)
 
     return {
         'searchform': SearchForm(),

+ 0 - 2
wagtail/images/views/images.py

@@ -76,8 +76,6 @@ def index(request):
     )
     if len(collections) < 2:
         collections = None
-    else:
-        collections = Collection.order_for_display(collections)
 
     # Create response
     if request.is_ajax():

+ 3 - 6
wagtail/images/views/multiple.py

@@ -10,7 +10,6 @@ from django.views.decorators.http import require_POST
 from django.views.decorators.vary import vary_on_headers
 
 from wagtail.admin.auth import PermissionPolicyChecker
-from wagtail.core.models import Collection
 from wagtail.images import get_image_model
 from wagtail.images.fields import ALLOWED_EXTENSIONS
 from wagtail.images.forms import get_image_form
@@ -46,11 +45,9 @@ def add(request):
     ImageForm = get_image_form(Image)
 
     collections = permission_policy.collections_user_has_permission_for(request.user, 'add')
-    if len(collections) > 1:
-        collections_to_choose = Collection.order_for_display(collections)
-    else:
+    if len(collections) < 2:
         # no need to show a collections chooser
-        collections_to_choose = None
+        collections = None
 
     if request.method == 'POST':
         if not request.is_ajax():
@@ -128,7 +125,7 @@ def add(request):
             'allowed_extensions': ALLOWED_EXTENSIONS,
             'error_max_file_size': form.fields['file'].error_messages['file_too_large_unknown_size'],
             'error_accepted_file_types': form.fields['file'].error_messages['invalid_image_extension'],
-            'collections': collections_to_choose,
+            'collections': collections,
             'form_media': form.media,
         })