diff --git a/opentech/apply/categories/admin.py b/opentech/apply/categories/admin.py index 805d8aeae3a290387b25ebf04f13cba0121c7b9d..2eb24d179ee07a77d238c5cb068d6458ab2b1162 100644 --- a/opentech/apply/categories/admin.py +++ b/opentech/apply/categories/admin.py @@ -1,9 +1,42 @@ +from django.conf.urls import url from wagtail.contrib.modeladmin.options import ModelAdmin -from .models import Category +from .admin_helpers import MetaCategoryButtonHelper +from .admin_views import AddChildMetaCategoryViewClass +from .models import Category, MetaCategory class CategoryAdmin(ModelAdmin): menu_label = 'Category Questions' menu_icon = 'list-ul' model = Category + + +class MetaCategoryAdmin(ModelAdmin): + model = MetaCategory + + menu_icon = 'tag' + + list_per_page = 50 + list_display = ('get_as_listing_header', 'get_parent') + search_fields = ('name',) + + inspect_view_enabled = True + inspect_view_fields = ('name', 'get_parent', 'id') + + button_helper_class = MetaCategoryButtonHelper + + def add_child_view(self, request, instance_pk): + kwargs = {'model_admin': self, 'parent_pk': instance_pk} + view_class = AddChildMetaCategoryViewClass + return view_class.as_view(**kwargs)(request) + + def get_admin_urls_for_registration(self): + """Add the new url for add child page to the registered URLs.""" + urls = super().get_admin_urls_for_registration() + add_child_url = url( + self.url_helper.get_action_url_pattern('add_child'), + self.add_child_view, + name=self.url_helper.get_action_url_name('add_child') + ) + return urls + (add_child_url, ) diff --git a/opentech/apply/categories/admin_helpers.py b/opentech/apply/categories/admin_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..9f786fced1de4030624668a1e96c39297d414ba2 --- /dev/null +++ b/opentech/apply/categories/admin_helpers.py @@ -0,0 +1,47 @@ +from django.contrib.admin.utils import quote + +from wagtail.contrib.modeladmin.helpers import ButtonHelper + + +class MetaCategoryButtonHelper(ButtonHelper): + def delete_button(self, pk, *args, **kwargs): + """Ensure that the delete button is not shown for root category.""" + instance = self.model.objects.get(pk=pk) + if instance.is_root(): + return + return super().delete_button(pk, *args, **kwargs) + + def prepare_classnames(self, start=None, add=None, exclude=None): + """Parse classname sets into final css classess list.""" + classnames = start or [] + classnames.extend(add or []) + return self.finalise_classname(classnames, exclude or []) + + def add_child_button(self, pk, child_verbose_name, **kwargs): + """Build a add child button, to easily add a child under category.""" + classnames = self.prepare_classnames( + start=self.edit_button_classnames + ['icon', 'icon-plus'], + add=kwargs.get('classnames_add'), + exclude=kwargs.get('classnames_exclude') + ) + return { + 'classname': classnames, + 'label': 'Add %s %s' % ( + child_verbose_name, self.verbose_name), + 'title': 'Add %s %s under this one' % ( + child_verbose_name, self.verbose_name), + 'url': self.url_helper.get_action_url('add_child', quote(pk)), + } + + def get_buttons_for_obj(self, obj, exclude=None, *args, **kwargs): + """Override the getting of buttons, prepending create child button.""" + buttons = super().get_buttons_for_obj(obj, *args, **kwargs) + + add_child_button = self.add_child_button( + pk=getattr(obj, self.opts.pk.attname), + child_verbose_name=getattr(obj, 'node_child_verbose_name'), + **kwargs + ) + buttons.append(add_child_button) + + return buttons diff --git a/opentech/apply/categories/admin_views.py b/opentech/apply/categories/admin_views.py new file mode 100644 index 0000000000000000000000000000000000000000..890bccbe451363c024e42286f885067a66515838 --- /dev/null +++ b/opentech/apply/categories/admin_views.py @@ -0,0 +1,31 @@ +from django.contrib.admin.utils import unquote +from django.shortcuts import get_object_or_404 + +from wagtail.contrib.modeladmin.views import CreateView + + +class AddChildMetaCategoryViewClass(CreateView): + """View class that can take an additional URL param for parent id.""" + + parent_pk = None + parent_instance = None + + def __init__(self, model_admin, parent_pk): + self.parent_pk = unquote(parent_pk) + object_qs = model_admin.model._default_manager.get_queryset() + object_qs = object_qs.filter(pk=self.parent_pk) + self.parent_instance = get_object_or_404(object_qs) + super().__init__(model_admin) + + def get_page_title(self): + """Generate a title that explains you are adding a child.""" + title = super().get_page_title() + return title + ' %s %s for %s' % ( + self.model.node_child_verbose_name, + self.opts.verbose_name, + self.parent_instance + ) + + def get_initial(self): + """Set the selected parent field to the parent_pk.""" + return {'parent': self.parent_pk} diff --git a/opentech/apply/categories/migrations/0002_metacategory.py b/opentech/apply/categories/migrations/0002_metacategory.py new file mode 100644 index 0000000000000000000000000000000000000000..0f47ec33488f216aeafa47b0d35b9f11dcbe92c3 --- /dev/null +++ b/opentech/apply/categories/migrations/0002_metacategory.py @@ -0,0 +1,30 @@ +# Generated by Django 2.0.9 on 2019-02-22 15:06 + +from django.db import migrations, models +import wagtail.search.index + + +class Migration(migrations.Migration): + + dependencies = [ + ('categories', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='MetaCategory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(max_length=255, unique=True)), + ('depth', models.PositiveIntegerField()), + ('numchild', models.PositiveIntegerField(default=0)), + ('name', models.CharField(help_text='Keep the name short, ideally one word.', max_length=50, unique=True)), + ('node_order_index', models.IntegerField(blank=True, default=0, editable=False)), + ], + options={ + 'verbose_name': 'Meta Category', + 'verbose_name_plural': 'Meta Categories', + }, + bases=(wagtail.search.index.Indexed, models.Model), + ), + ] diff --git a/opentech/apply/categories/models.py b/opentech/apply/categories/models.py index 3200f4bf556a39dec0235ad72db8b50223f9f137..81ce64591b3e827bfe49197c62e2d7cb18f07824 100644 --- a/opentech/apply/categories/models.py +++ b/opentech/apply/categories/models.py @@ -1,11 +1,19 @@ +from django import forms +from django.core.exceptions import PermissionDenied from django.db import models +from django.template.loader import render_to_string + from modelcluster.fields import ParentalKey from modelcluster.models import ClusterableModel from wagtail.admin.edit_handlers import ( FieldPanel, InlinePanel, ) +from wagtail.admin.forms import WagtailAdminModelForm from wagtail.core.models import Orderable +from wagtail.search import index + +from treebeard.mp_tree import MP_Node class Option(Orderable): @@ -33,3 +41,107 @@ class Category(ClusterableModel): class Meta: verbose_name_plural = 'Categories' + + +class MetaCategory(index.Indexed, MP_Node): + """ Hierarchal "Meta" category """ + name = models.CharField( + max_length=50, unique=True, help_text='Keep the name short, ideally one word.' + ) + + # node tree specific fields and attributes + node_order_index = models.IntegerField(blank=True, default=0, editable=False) + node_child_verbose_name = 'child' + + # important: node_order_by should NOT be changed after first Node created + node_order_by = ['node_order_index', 'name'] + + panels = [ + FieldPanel('parent'), + FieldPanel('name'), + ] + + def get_as_listing_header(self): + depth = self.get_depth() + rendered = render_to_string( + 'categories/admin/includes/meta_category_list_header.html', + { + 'depth': depth, + 'depth_minus_1': depth - 1, + 'is_root': self.is_root(), + 'name': self.name, + } + ) + return rendered + get_as_listing_header.short_description = 'Name' + get_as_listing_header.admin_order_field = 'name' + + def get_parent(self, *args, **kwargs): + return super().get_parent(*args, **kwargs) + get_parent.short_description = 'Parent' + + search_fields = [ + index.SearchField('name', partial_match=True), + ] + + def delete(self): + if self.is_root(): + raise PermissionDenied('Cannot delete root Category.') + else: + super().delete() + + def __str__(self): + return self.name + + class Meta: + verbose_name = 'Meta Category' + verbose_name_plural = 'Meta Categories' + + +class MetaCategoryChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj): + depth_line = '-' * (obj.get_depth() - 1) + return "{} {}".format(depth_line, super().label_from_instance(obj)) + + +class MetaCategoryForm(WagtailAdminModelForm): + parent = MetaCategoryChoiceField( + required=True, + queryset=MetaCategory.objects.all(), + empty_label=None, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + instance = kwargs['instance'] + + if instance.is_root() or MetaCategory.objects.count() == 0: + self.fields['parent'].disabled = True + self.fields['parent'].required = False + self.fields['parent'].empty_label = 'N/A - Root Category' + self.fields['parent'].widget = forms.HiddenInput() + + self.fields['name'].label += ' (Root - First category can be named root)' + elif instance.id: + self.fields['parent'].initial = instance.get_parent() + + def save(self, commit=True, *args, **kwargs): + instance = super().save(commit=False, *args, **kwargs) + parent = self.cleaned_data['parent'] + + if not commit: + return instance + + if instance.id is None: + if MetaCategory.objects.all().count() == 0: + MetaCategory.add_root(instance=instance) + else: + instance = parent.add_child(instance=instance) + else: + instance.save() + if instance.get_parent() != parent: + instance.move(parent, pos='sorted-child') + return instance + + +MetaCategory.base_form_class = MetaCategoryForm diff --git a/opentech/apply/categories/templates/categories/admin/includes/meta_category_list_header.html b/opentech/apply/categories/templates/categories/admin/includes/meta_category_list_header.html new file mode 100644 index 0000000000000000000000000000000000000000..b6a816694bcffbec1fc4fb524ce0931a9e111fcb --- /dev/null +++ b/opentech/apply/categories/templates/categories/admin/includes/meta_category_list_header.html @@ -0,0 +1,9 @@ +{% if is_root %} + <span><strong>{{ name }}</strong></span> +{% else %} + <span> + <span class="inline-block" style="margin-left:{{ depth }}em; font-size:{% if depth is 1 %}90{% elif depth is 2 %}80{% else %}100{% endif %}%;"></span> + <i class="icon icon-fa-level-up icon-fa-rotate-90" style="display: inline-block;"></i> + {{ name }} + </span> +{% endif %} diff --git a/opentech/apply/funds/admin.py b/opentech/apply/funds/admin.py index 689f73027216344b55a4b82eed731de5b7b55dfb..4dc9d3679087fbc58a8f9148b32a2b6da20b7e42 100644 --- a/opentech/apply/funds/admin.py +++ b/opentech/apply/funds/admin.py @@ -10,7 +10,7 @@ from .admin_helpers import ( RoundFundChooserView, ) from .models import ApplicationForm, FundType, LabType, RequestForPartners, Round, SealedRound -from opentech.apply.categories.admin import CategoryAdmin +from opentech.apply.categories.admin import CategoryAdmin, MetaCategoryAdmin class BaseRoundAdmin(ModelAdmin): @@ -106,4 +106,5 @@ class ApplyAdminGroup(ModelAdminGroup): ReviewFormAdmin, CategoryAdmin, ScreeningStatusAdmin, + MetaCategoryAdmin, )