From 9313ba08eded184988731a90b7670eb0d92dc1cf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Feb 2017 14:15:15 -0500 Subject: [PATCH] Implemented recursive regions with django-mptt --- docs/data-model/dcim.md | 2 +- netbox/dcim/admin.py | 6 ++++-- netbox/dcim/api/serializers.py | 11 ++++++----- netbox/dcim/forms.py | 13 ++++++++----- netbox/dcim/migrations/0031_regions.py | 10 ++++++++-- netbox/dcim/models.py | 10 +++++++--- netbox/dcim/tables.py | 23 +++++++++++++++++++++-- netbox/netbox/settings.py | 1 + netbox/templates/dcim/site.html | 10 ++++++++-- netbox/utilities/forms.py | 19 +++++++++++++++---- requirements.txt | 1 + 11 files changed, 80 insertions(+), 26 deletions(-) diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index 7f83c685c..932affe69 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -8,7 +8,7 @@ Sites can be assigned an optional facility ID to identify the actual facility ho ### Regions -Sites can be arranged by geographic region. A region might represent a continent, country, city, campus, or other area depending on your use case. Region assignment is optional. +Sites can optionally be arranged by geographic region. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. --- diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py index 147dc174b..16f07dfcf 100644 --- a/netbox/dcim/admin.py +++ b/netbox/dcim/admin.py @@ -1,6 +1,8 @@ from django.contrib import admin from django.db.models import Count +from mptt.admin import MPTTModelAdmin + from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, @@ -10,8 +12,8 @@ from .models import ( @admin.register(Region) -class RegionAdmin(admin.ModelAdmin): - list_display = ['name', 'slug'] +class RegionAdmin(MPTTModelAdmin): + list_display = ['name', 'parent', 'slug'] prepopulated_fields = { 'slug': ['name'], } diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 928506bdc..d6162f48f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -15,17 +15,18 @@ from tenancy.api.serializers import TenantNestedSerializer # Regions # -class RegionSerializer(serializers.ModelSerializer): +class RegionNestedSerializer(serializers.ModelSerializer): class Meta: - model = RackGroup + model = Region fields = ['id', 'name', 'slug'] -class RegionNestedSerializer(RegionSerializer): +class RegionSerializer(serializers.ModelSerializer): - class Meta(RegionSerializer.Meta): - pass + class Meta: + model = Region + fields = ['id', 'name', 'slug', 'parent'] # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index eb96932d7..da9b2411b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,5 +1,7 @@ import re +from mptt.forms import TreeNodeChoiceField + from django import forms from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ValidationError @@ -11,7 +13,7 @@ from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, - SmallTextarea, SlugField, + SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField @@ -72,7 +74,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): class Meta: model = Region - fields = ['name', 'slug'] + fields = ['parent', 'name', 'slug'] # @@ -80,6 +82,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): # class SiteForm(BootstrapMixin, CustomFieldForm): + region = TreeNodeChoiceField(queryset=Region.objects.all()) slug = SlugField() comments = CommentField() @@ -127,7 +130,7 @@ class SiteImportForm(BootstrapMixin, BulkImportForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) - region = forms.ModelChoiceField(queryset=Region.objects.all(), required=False) + region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN') @@ -138,10 +141,10 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site q = forms.CharField(required=False, label='Search') - region = FilterChoiceField( + region = FilterTreeNodeMultipleChoiceField( queryset=Region.objects.annotate(filter_count=Count('sites')), to_field_name='slug', - null_option=(0, 'None') + required=False, ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('sites')), diff --git a/netbox/dcim/migrations/0031_regions.py b/netbox/dcim/migrations/0031_regions.py index 256bd2743..d4fd4db5e 100644 --- a/netbox/dcim/migrations/0031_regions.py +++ b/netbox/dcim/migrations/0031_regions.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.4 on 2017-02-28 14:48 +# Generated by Django 1.10.4 on 2017-02-28 17:14 from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion +import mptt.fields class Migration(migrations.Migration): @@ -19,9 +20,14 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=50, unique=True)), ('slug', models.SlugField(unique=True)), + ('lft', models.PositiveIntegerField(db_index=True, editable=False)), + ('rght', models.PositiveIntegerField(db_index=True, editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(db_index=True, editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')), ], options={ - 'ordering': ['name'], + 'abstract': False, }, ), migrations.AddField( diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index dc0a676d6..9beba5bde 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,5 +1,7 @@ from collections import OrderedDict +from mptt.models import MPTTModel, TreeForeignKey + from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -205,15 +207,16 @@ RPC_CLIENT_CHOICES = [ # @python_2_unicode_compatible -class Region(models.Model): +class Region(MPTTModel): """ Sites can be grouped within geographic Regions. """ + parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True) name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - class Meta: - ordering = ['name'] + class MPTTMeta: + order_insertion_by = ['name'] def __str__(self): return self.name @@ -267,6 +270,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): return csv_format([ self.name, self.slug, + self.region.name if self.region else None, self.tenant.name if self.tenant else None, self.facility, self.asn, diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 2a013b48d..a6f6dbdc2 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -10,6 +10,24 @@ from .models import ( ) +REGION_LINK = """ +{% if record.get_children %} + +{% else %} + +{% endif %} + {{ record.name }} + +""" + +SITE_REGION_LINK = """ +{% if record.region %} + {{ record.region }} +{% else %} + — +{% endif %} +""" + COLOR_LABEL = """ """ @@ -88,7 +106,8 @@ UTILIZATION_GRAPH = """ class RegionTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') + # name = tables.LinkColumn(verbose_name='Name') + name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False) site_count = tables.Column(verbose_name='Sites') slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn( @@ -110,7 +129,7 @@ class SiteTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') facility = tables.Column(verbose_name='Facility') - region = tables.LinkColumn(verbose_name='Region') + region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') asn = tables.Column(verbose_name='ASN') rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks') diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 48786a3f8..639273509 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -104,6 +104,7 @@ INSTALLED_APPS = ( 'django.contrib.humanize', 'debug_toolbar', 'django_tables2', + 'mptt', 'rest_framework', 'rest_framework_swagger', 'circuits', diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 890d34926..bd7171700 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -9,9 +9,11 @@
@@ -62,6 +64,10 @@ Region {% if site.region %} + {% for region in site.region.get_ancestors %} + {{ region }} + + {% endfor %} {{ site.region }} {% else %} None diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 6eb11c208..76ce1796c 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -2,6 +2,8 @@ import csv import itertools import re +from mptt.forms import TreeNodeMultipleChoiceField + from django import forms from django.conf import settings from django.core.urlresolvers import reverse_lazy @@ -365,7 +367,7 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class FilterChoiceField(forms.ModelMultipleChoiceField): +class FilterChoiceFieldMixin(object): iterator = forms.models.ModelChoiceIterator def __init__(self, null_option=None, *args, **kwargs): @@ -374,12 +376,13 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): kwargs['required'] = False if 'widget' not in kwargs: kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) - super(FilterChoiceField, self).__init__(*args, **kwargs) + super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs) def label_from_instance(self, obj): + label = super(FilterChoiceFieldMixin, self).label_from_instance(obj) if hasattr(obj, 'filter_count'): - return u'{} ({})'.format(obj, obj.filter_count) - return force_text(obj) + return u'{} ({})'.format(label, obj.filter_count) + return label def _get_choices(self): if hasattr(self, '_choices'): @@ -391,6 +394,14 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): choices = property(_get_choices, forms.ChoiceField._set_choices) +class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): + pass + + +class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): + pass + + class LaxURLField(forms.URLField): """ Custom URLField which allows any valid URL scheme diff --git a/requirements.txt b/requirements.txt index caa678f4c..2d1c81ffa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ cryptography>=1.4 Django>=1.10 django-debug-toolbar>=1.6 django-filter==0.15.3 +django-mptt==0.8.7 django-rest-swagger==0.3.10 django-tables2>=1.2.5 djangorestframework>=3.5.0