Implement nested collections

pull/6273/merge
Robert Rollins 2020-09-02 22:12:30 +01:00 zatwierdzone przez Andy Babic
rodzic 0d8301e28f
commit e404f83cd1
17 zmienionych plików z 238 dodań i 63 usunięć

Wyświetl plik

@ -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,

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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

Wyświetl plik

@ -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 = [

Wyświetl plik

@ -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')

Wyświetl plik

@ -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,
}

Wyświetl plik

@ -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()

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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()

Wyświetl plik

@ -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():

Wyświetl plik

@ -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,
})

Wyświetl plik

@ -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)

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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(),

Wyświetl plik

@ -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():

Wyświetl plik

@ -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,
})