Parcourir la source

Implemented recursive regions with django-mptt

Jeremy Stretch il y a 8 ans
Parent
commit
9313ba08ed

+ 1 - 1
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.
 
 ---
 

+ 4 - 2
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'],
     }

+ 6 - 5
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']
 
 
 #

+ 8 - 5
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')),

+ 8 - 2
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(

+ 7 - 3
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,

+ 21 - 2
netbox/dcim/tables.py

@@ -10,6 +10,24 @@ from .models import (
 )
 
 
+REGION_LINK = """
+{% if record.get_children %}
+    <span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i></a>
+{% else %}
+    <span style="padding-left: {{ record.get_ancestors|length }}9px">
+{% endif %}
+    {{ record.name }}
+</span>
+"""
+
+SITE_REGION_LINK = """
+{% if record.region %}
+    <a href="{% url 'dcim:site_list' %}?region={{ record.region.slug }}">{{ record.region }}</a>
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 COLOR_LABEL = """
 <label class="label" style="background-color: #{{ record.color }}">{{ record }}</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')

+ 1 - 0
netbox/netbox/settings.py

@@ -104,6 +104,7 @@ INSTALLED_APPS = (
     'django.contrib.humanize',
     'debug_toolbar',
     'django_tables2',
+    'mptt',
     'rest_framework',
     'rest_framework_swagger',
     'circuits',

+ 8 - 2
netbox/templates/dcim/site.html

@@ -9,9 +9,11 @@
 <div class="row">
     <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
             {% if site.region %}
-                <li> <a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
+                {% for region in site.region.get_ancestors %}
+                    <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
+                {% endfor %}
+                <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
             {% endif %}
             <li>{{ site }}</li>
         </ol>
@@ -62,6 +64,10 @@
                     <td>Region</td>
                     <td>
                         {% if site.region %}
+                            {% for region in site.region.get_ancestors %}
+                                <a href="{{ region.get_absolute_url }}">{{ region }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endfor %}
                             <a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a>
                         {% else %}
                             <span class="text-muted">None</span>

+ 15 - 4
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

+ 1 - 0
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