Implemented recursive regions with django-mptt

This commit is contained in:
Jeremy Stretch 2017-02-28 14:15:15 -05:00
parent f3b9930dea
commit 9313ba08ed
11 changed files with 80 additions and 26 deletions

View File

@ -8,7 +8,7 @@ Sites can be assigned an optional facility ID to identify the actual facility ho
### Regions ### 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.
--- ---

View File

@ -1,6 +1,8 @@
from django.contrib import admin from django.contrib import admin
from django.db.models import Count from django.db.models import Count
from mptt.admin import MPTTModelAdmin
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
@ -10,8 +12,8 @@ from .models import (
@admin.register(Region) @admin.register(Region)
class RegionAdmin(admin.ModelAdmin): class RegionAdmin(MPTTModelAdmin):
list_display = ['name', 'slug'] list_display = ['name', 'parent', 'slug']
prepopulated_fields = { prepopulated_fields = {
'slug': ['name'], 'slug': ['name'],
} }

View File

@ -15,17 +15,18 @@ from tenancy.api.serializers import TenantNestedSerializer
# Regions # Regions
# #
class RegionSerializer(serializers.ModelSerializer): class RegionNestedSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = RackGroup model = Region
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class RegionNestedSerializer(RegionSerializer): class RegionSerializer(serializers.ModelSerializer):
class Meta(RegionSerializer.Meta): class Meta:
pass model = Region
fields = ['id', 'name', 'slug', 'parent']
# #

View File

@ -1,5 +1,7 @@
import re import re
from mptt.forms import TreeNodeChoiceField
from django import forms from django import forms
from django.contrib.postgres.forms.array import SimpleArrayField from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -11,7 +13,7 @@ from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField,
CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
SmallTextarea, SlugField, SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField,
) )
from .formfields import MACAddressFormField from .formfields import MACAddressFormField
@ -72,7 +74,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = Region model = Region
fields = ['name', 'slug'] fields = ['parent', 'name', 'slug']
# #
@ -80,6 +82,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
# #
class SiteForm(BootstrapMixin, CustomFieldForm): class SiteForm(BootstrapMixin, CustomFieldForm):
region = TreeNodeChoiceField(queryset=Region.objects.all())
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
@ -127,7 +130,7 @@ class SiteImportForm(BootstrapMixin, BulkImportForm):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) 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) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN') 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): class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site model = Site
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
region = FilterChoiceField( region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.annotate(filter_count=Count('sites')), queryset=Region.objects.annotate(filter_count=Count('sites')),
to_field_name='slug', to_field_name='slug',
null_option=(0, 'None') required=False,
) )
tenant = FilterChoiceField( tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('sites')), queryset=Tenant.objects.annotate(filter_count=Count('sites')),

View File

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*- # -*- 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 __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration): 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')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)), ('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(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={ options={
'ordering': ['name'], 'abstract': False,
}, },
), ),
migrations.AddField( migrations.AddField(

View File

@ -1,5 +1,7 @@
from collections import OrderedDict from collections import OrderedDict
from mptt.models import MPTTModel, TreeForeignKey
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -205,15 +207,16 @@ RPC_CLIENT_CHOICES = [
# #
@python_2_unicode_compatible @python_2_unicode_compatible
class Region(models.Model): class Region(MPTTModel):
""" """
Sites can be grouped within geographic Regions. 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) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
class Meta: class MPTTMeta:
ordering = ['name'] order_insertion_by = ['name']
def __str__(self): def __str__(self):
return self.name return self.name
@ -267,6 +270,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
return csv_format([ return csv_format([
self.name, self.name,
self.slug, self.slug,
self.region.name if self.region else None,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.facility, self.facility,
self.asn, self.asn,

View File

@ -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 = """ COLOR_LABEL = """
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label> <label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
""" """
@ -88,7 +106,8 @@ UTILIZATION_GRAPH = """
class RegionTable(BaseTable): class RegionTable(BaseTable):
pk = ToggleColumn() 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') site_count = tables.Column(verbose_name='Sites')
slug = tables.Column(verbose_name='Slug') slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
@ -110,7 +129,7 @@ class SiteTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
facility = tables.Column(verbose_name='Facility') 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') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
asn = tables.Column(verbose_name='ASN') asn = tables.Column(verbose_name='ASN')
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks') rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')

View File

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

View File

@ -9,9 +9,11 @@
<div class="row"> <div class="row">
<div class="col-sm-8 col-md-9"> <div class="col-sm-8 col-md-9">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
{% if site.region %} {% 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 %} {% endif %}
<li>{{ site }}</li> <li>{{ site }}</li>
</ol> </ol>
@ -62,6 +64,10 @@
<td>Region</td> <td>Region</td>
<td> <td>
{% if site.region %} {% 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> <a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>

View File

@ -2,6 +2,8 @@ import csv
import itertools import itertools
import re import re
from mptt.forms import TreeNodeMultipleChoiceField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
@ -365,7 +367,7 @@ class SlugField(forms.SlugField):
self.widget.attrs['slug-source'] = slug_source self.widget.attrs['slug-source'] = slug_source
class FilterChoiceField(forms.ModelMultipleChoiceField): class FilterChoiceFieldMixin(object):
iterator = forms.models.ModelChoiceIterator iterator = forms.models.ModelChoiceIterator
def __init__(self, null_option=None, *args, **kwargs): def __init__(self, null_option=None, *args, **kwargs):
@ -374,12 +376,13 @@ class FilterChoiceField(forms.ModelMultipleChoiceField):
kwargs['required'] = False kwargs['required'] = False
if 'widget' not in kwargs: if 'widget' not in kwargs:
kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) 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): def label_from_instance(self, obj):
label = super(FilterChoiceFieldMixin, self).label_from_instance(obj)
if hasattr(obj, 'filter_count'): if hasattr(obj, 'filter_count'):
return u'{} ({})'.format(obj, obj.filter_count) return u'{} ({})'.format(label, obj.filter_count)
return force_text(obj) return label
def _get_choices(self): def _get_choices(self):
if hasattr(self, '_choices'): if hasattr(self, '_choices'):
@ -391,6 +394,14 @@ class FilterChoiceField(forms.ModelMultipleChoiceField):
choices = property(_get_choices, forms.ChoiceField._set_choices) choices = property(_get_choices, forms.ChoiceField._set_choices)
class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
pass
class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField):
pass
class LaxURLField(forms.URLField): class LaxURLField(forms.URLField):
""" """
Custom URLField which allows any valid URL scheme Custom URLField which allows any valid URL scheme

View File

@ -3,6 +3,7 @@ cryptography>=1.4
Django>=1.10 Django>=1.10
django-debug-toolbar>=1.6 django-debug-toolbar>=1.6
django-filter==0.15.3 django-filter==0.15.3
django-mptt==0.8.7
django-rest-swagger==0.3.10 django-rest-swagger==0.3.10
django-tables2>=1.2.5 django-tables2>=1.2.5
djangorestframework>=3.5.0 djangorestframework>=3.5.0