mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 11:42:52 -06:00
Implemented recursive regions with django-mptt
This commit is contained in:
parent
f3b9930dea
commit
9313ba08ed
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -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'],
|
||||||
}
|
}
|
||||||
|
@ -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']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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')),
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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 %}
|
||||||
|
—
|
||||||
|
{% 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')
|
||||||
|
@ -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',
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user