Browse Source

Implement a new 'Facility' object type

Baptiste Jonglez 7 years ago
parent
commit
0766ec17ef

+ 39 - 4
netbox/dcim/api/serializers.py

@@ -14,7 +14,7 @@ from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    RackReservation, RackRole, Region, Site, Facility, VirtualChassis,
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress, VLAN
@@ -52,6 +52,41 @@ class WritableRegionSerializer(ValidatedModelSerializer):
 
 
 #
+# Facilities
+#
+
+class FacilitySerializer(CustomFieldModelSerializer):
+    tenant = NestedTenantSerializer()
+
+    class Meta:
+        model = Facility
+        fields = [
+            'id', 'name', 'slug', 'tenant', 'description', 'peeringdb_id', 'city', 'latitude', 'longitude',
+            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'custom_fields', 'created', 'last_updated', 'count_sites',
+        ]
+
+
+class NestedFacilitySerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:facility-detail')
+
+    class Meta:
+        model = Facility
+        fields = ['id', 'url', 'name', 'slug']
+
+
+class WritableFacilitySerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = Facility
+        fields = [
+            'id', 'name', 'slug', 'tenant', 'description', 'peeringdb_id', 'city', 'latitude', 'longitude',
+            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'custom_fields', 'created', 'last_updated',
+        ]
+
+
+#
 # Sites
 #
 
@@ -59,13 +94,14 @@ class SiteSerializer(CustomFieldModelSerializer):
     status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
     region = NestedRegionSerializer()
     tenant = NestedTenantSerializer()
+    facility = NestedFacilitySerializer()
     time_zone = TimeZoneField(required=False)
 
     class Meta:
         model = Site
         fields = [
             'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
-            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'comments',
             'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
             'count_circuits',
         ]
@@ -85,8 +121,7 @@ class WritableSiteSerializer(CustomFieldModelSerializer):
     class Meta:
         model = Site
         fields = [
-            'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
-            'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'comments',
             'custom_fields', 'created', 'last_updated',
         ]
 

+ 1 - 0
netbox/dcim/api/urls.py

@@ -20,6 +20,7 @@ router.APIRootView = DCIMRootView
 router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice')
 
 # Sites
+router.register(r'facilities', views.FacilityViewSet)
 router.register(r'regions', views.RegionViewSet)
 router.register(r'sites', views.SiteViewSet)
 

+ 13 - 2
netbox/dcim/api/views.py

@@ -19,7 +19,7 @@ from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    RackReservation, RackRole, Region, Site, Facility, VirtualChassis,
 )
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
@@ -57,11 +57,22 @@ class RegionViewSet(ModelViewSet):
 
 
 #
+# Facilities
+#
+
+class FacilityViewSet(CustomFieldModelViewSet):
+    queryset = Facility.objects.select_related('tenant')
+    serializer_class = serializers.FacilitySerializer
+    write_serializer_class = serializers.WritableFacilitySerializer
+    filter_class = filters.FacilityFilter
+
+
+#
 # Sites
 #
 
 class SiteViewSet(CustomFieldModelViewSet):
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.select_related('region', 'tenant', 'facility')
     serializer_class = serializers.SiteSerializer
     write_serializer_class = serializers.WritableSiteSerializer
     filter_class = filters.SiteFilter

+ 53 - 8
netbox/dcim/filters.py

@@ -18,7 +18,7 @@ from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    RackReservation, RackRole, Region, Site, Facility, VirtualChassis,
 )
 
 
@@ -52,6 +52,47 @@ class RegionFilter(django_filters.FilterSet):
         return queryset.filter(qs_filter)
 
 
+class FacilityFilter(CustomFieldFilterSet, django_filters.FilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant__slug',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
+
+    class Meta:
+        model = Facility
+        fields = ['q', 'name', 'slug', 'contact_name', 'contact_phone', 'contact_email']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(physical_address__icontains=value) |
+            Q(shipping_address__icontains=value) |
+            Q(contact_name__icontains=value) |
+            Q(contact_phone__icontains=value) |
+            Q(contact_email__icontains=value) |
+            Q(comments__icontains=value)
+        )
+        try:
+            qs_filter |= Q(asn=int(value.strip()))
+        except ValueError:
+            pass
+        return queryset.filter(qs_filter)
+
+
 class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
@@ -82,23 +123,27 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Tenant (slug)',
     )
+    facility_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Facility.objects.all(),
+        label='Facility (ID)',
+    )
+    facility = django_filters.ModelMultipleChoiceFilter(
+        name='facility__slug',
+        queryset=Facility.objects.all(),
+        to_field_name='slug',
+        label='Facility (slug)',
+    )
 
     class Meta:
         model = Site
-        fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
+        fields = ['q', 'name', 'slug', 'asn']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         qs_filter = (
             Q(name__icontains=value) |
-            Q(facility__icontains=value) |
             Q(description__icontains=value) |
-            Q(physical_address__icontains=value) |
-            Q(shipping_address__icontains=value) |
-            Q(contact_name__icontains=value) |
-            Q(contact_phone__icontains=value) |
-            Q(contact_email__icontains=value) |
             Q(comments__icontains=value)
         )
         try:

File diff suppressed because it is too large
+ 1762 - 0
netbox/dcim/fixtures/facilities-fr-peeringdb.json


+ 83 - 10
netbox/dcim/forms.py

@@ -31,7 +31,7 @@ from .models import (
     DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
-    RackRole, Region, Site, VirtualChassis
+    RackRole, Region, Site, Facility, VirtualChassis
 )
 
 DEVICE_BY_PK_RE = '{\d+\}'
@@ -101,18 +101,17 @@ class RegionFilterForm(BootstrapMixin, forms.Form):
 
 
 #
-# Sites
+# Facilities
 #
 
-class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
-    region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
+class FacilityForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     slug = SlugField()
     comments = CommentField()
 
     class Meta:
-        model = Site
+        model = Facility
         fields = [
-            'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+            'name', 'slug', 'tenant_group', 'tenant', 'description', 'peeringdb_id', 'city', 'latitude', 'longitude',
             'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
         ]
         widgets = {
@@ -120,13 +119,72 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
         }
         help_texts = {
+            'name': "Full name of the facility",
+            'description': "Short description (will appear in facilities list)",
+            'physical_address': "Physical location of the building (e.g. for GPS)",
+            'shipping_address': "If different from the physical address"
+        }
+
+
+class FacilityCSVForm(forms.ModelForm):
+    tenant = forms.ModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of assigned tenant',
+        error_messages={
+            'invalid_choice': 'Tenant not found.',
+        }
+    )
+
+    class Meta:
+        model = Facility
+        fields = Facility.csv_headers
+        help_texts = {
+            'name': 'Facility name',
+            'slug': 'URL-friendly slug',
+        }
+
+
+class FacilityBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Facility.objects.all(), widget=forms.MultipleHiddenInput)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    description = forms.CharField(max_length=100, required=False)
+
+    class Meta:
+        nullable_fields = ['tenant', 'description']
+
+
+class FacilityFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = Facility
+    q = forms.CharField(required=False, label='Search')
+    tenant = FilterChoiceField(
+        queryset=Tenant.objects.annotate(filter_count=Count('sites')),
+        to_field_name='slug',
+        null_label='-- None --'
+    )
+
+
+#
+# Sites
+#
+
+class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+    region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
+    slug = SlugField()
+    comments = CommentField()
+
+    class Meta:
+        model = Site
+        fields = [
+            'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'comments',
+        ]
+        help_texts = {
             'name': "Full name of the site",
-            'facility': "Data center provider and facility (e.g. Equinix NY7)",
+            'facility': "Data center provider and facility",
             'asn': "BGP autonomous system number",
             'time_zone': "Local time zone",
             'description': "Short description (will appear in sites list)",
-            'physical_address': "Physical location of the building (e.g. for GPS)",
-            'shipping_address': "If different from the physical address"
         }
 
 
@@ -154,6 +212,15 @@ class SiteCSVForm(forms.ModelForm):
             'invalid_choice': 'Tenant not found.',
         }
     )
+    facility = forms.ModelChoiceField(
+        queryset=Facility.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of facility',
+        error_messages={
+            'invalid_choice': 'Facility not found.',
+        }
+    )
 
     class Meta:
         model = Site
@@ -170,12 +237,13 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
     region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    facility = forms.ModelChoiceField(queryset=Facility.objects.all(), required=False)
     asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
     description = forms.CharField(max_length=100, required=False)
     time_zone = TimeZoneFormField(required=False)
 
     class Meta:
-        nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
+        nullable_fields = ['region', 'tenant', 'facility', 'asn', 'description', 'time_zone']
 
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -197,6 +265,11 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_label='-- None --'
     )
+    facility = FilterChoiceField(
+        queryset=Facility.objects.annotate(filter_count=Count('sites')),
+        to_field_name='slug',
+        null_label='-- None --'
+    )
 
 
 #

+ 0 - 0
netbox/dcim/management/__init__.py


+ 0 - 0
netbox/dcim/management/commands/__init__.py


+ 59 - 0
netbox/dcim/management/commands/import_peeringdb_facilities.py

@@ -0,0 +1,59 @@
+from __future__ import unicode_literals
+
+import re
+
+import requests
+from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+from django.utils.text import slugify
+
+from dcim.models import Facility
+
+
+class Command(BaseCommand):
+    help = "Import facilities from PeeringDB, with optional filtering"
+
+    def add_arguments(self, parser):
+        parser.add_argument('--min-networks', type=int, help="Only keep facilities with at least this number of networks")
+        parser.add_argument('--country', '-c', help="Only keep facilities for the given country code (e.g. 'FR')")
+
+    def handle(self, *args, **options):
+        url = 'https://peeringdb.com/api/fac'
+        if options['country']:
+            url += '?country=' + options['country']
+        r = requests.get(url=url)
+        count = 0
+        for fac in r.json()['data']:
+            if options['min_networks'] and fac['net_count'] < options['min_networks']:
+                continue
+            # Try to parse peeringDB name, to have a short name and an optional description
+            name = fac['name']
+            description = list()
+            while True:
+                match = re.match(r'(.*) \((.*?)\)', name)
+                if not match:
+                    break
+                groups = match.groups()
+                name = groups[0]
+                description.insert(0, groups[1])
+            for d in description:
+                if len(d) <= 4:
+                    name += ' ({})'.format(d)
+            description = ', '.join([d for d in description if len(d) > 4])
+            address = "{}\n{}\n{} {}\n{}".format(fac['address1'],
+                                                 fac['address2'],
+                                                 fac['zipcode'],
+                                                 fac['city'],
+                                                 fac['country'])
+            f = Facility(name=name,
+                         slug=slugify(name),
+                         description=description,
+                         peeringdb_id=fac['id'],
+                         city=fac['city'],
+                         latitude=fac['latitude'],
+                         longitude=fac['longitude'],
+                         physical_address=address)
+            f.save()
+            count += 1
+        self.stdout.write("Finished, import {} facilities!".format(count))

+ 72 - 0
netbox/dcim/migrations/0056_auto_20180606_1039_squashed_0057_auto_20180606_1312.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.13 on 2018-06-06 14:36
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.models
+
+
+class Migration(migrations.Migration):
+
+    replaces = [('dcim', '0056_auto_20180606_1039'), ('dcim', '0057_auto_20180606_1312')]
+
+    dependencies = [
+        ('tenancy', '0003_unicode_literals'),
+        ('dcim', '0055_virtualchassis_ordering'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Facility',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateField(auto_now_add=True)),
+                ('last_updated', models.DateTimeField(auto_now=True)),
+                ('name', models.CharField(max_length=50, unique=True)),
+                ('slug', models.SlugField(unique=True)),
+                ('description', models.CharField(blank=True, max_length=100)),
+                ('peeringdb_id', models.PositiveIntegerField(blank=True, null=True, verbose_name='Facility ID in PeeringDB')),
+                ('latitude', models.FloatField(blank=True, null=True)),
+                ('longitude', models.FloatField(blank=True, null=True)),
+                ('physical_address', models.CharField(blank=True, max_length=200)),
+                ('shipping_address', models.CharField(blank=True, max_length=200)),
+                ('contact_name', models.CharField(blank=True, max_length=50)),
+                ('contact_phone', models.CharField(blank=True, max_length=20)),
+                ('contact_email', models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail')),
+                ('comments', models.TextField(blank=True)),
+                ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='facilities', to='tenancy.Tenant')),
+                ('city', models.CharField(blank=True, max_length=100)),
+            ],
+            options={
+                'ordering': ['name'],
+                'verbose_name_plural': 'facilities',
+            },
+            bases=(models.Model, extras.models.CustomFieldModel),
+        ),
+        migrations.RemoveField(
+            model_name='site',
+            name='contact_email',
+        ),
+        migrations.RemoveField(
+            model_name='site',
+            name='contact_name',
+        ),
+        migrations.RemoveField(
+            model_name='site',
+            name='contact_phone',
+        ),
+        migrations.RemoveField(
+            model_name='site',
+            name='physical_address',
+        ),
+        migrations.RemoveField(
+            model_name='site',
+            name='shipping_address',
+        ),
+        migrations.AlterField(
+            model_name='site',
+            name='facility',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Facility'),
+        ),
+    ]

+ 75 - 16
netbox/dcim/models.py

@@ -63,6 +63,77 @@ class Region(MPTTModel):
 
 
 #
+# Facilities
+#
+
+class FacilityManager(NaturalOrderByManager):
+
+    def get_queryset(self):
+        return self.natural_order_by('name')
+
+
+@python_2_unicode_compatible
+class Facility(CreatedUpdatedModel, CustomFieldModel):
+    """
+    A Facility represents a building, typically a datacenter, that can host Sites from several tenants.
+    """
+    name = models.CharField(max_length=50, unique=True)
+    slug = models.SlugField(unique=True)
+    tenant = models.ForeignKey(Tenant, related_name='facilities', blank=True, null=True, on_delete=models.PROTECT)
+    description = models.CharField(max_length=100, blank=True)
+    peeringdb_id = models.PositiveIntegerField(blank=True, null=True, verbose_name='Facility ID in PeeringDB')
+    city = models.CharField(max_length=100, blank=True)
+    latitude = models.FloatField(blank=True, null=True)
+    longitude = models.FloatField(blank=True, null=True)
+    physical_address = models.CharField(max_length=200, blank=True)
+    shipping_address = models.CharField(max_length=200, blank=True)
+    contact_name = models.CharField(max_length=50, blank=True)
+    contact_phone = models.CharField(max_length=20, blank=True)
+    contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
+    comments = models.TextField(blank=True)
+    custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+
+    objects = FacilityManager()
+
+    csv_headers = [
+        'name', 'slug', 'tenant', 'description', 'peeringdb_id', 'city', 'latitude', 'longitude',
+        'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+    ]
+
+    class Meta:
+        ordering = ['name']
+        verbose_name_plural = 'facilities'
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:facility', args=[self.slug])
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.tenant.name if self.tenant else None,
+            self.description,
+            self.peeringdb_id,
+            self.city,
+            self.latitude,
+            self.longitude,
+            self.physical_address,
+            self.shipping_address,
+            self.contact_name,
+            self.contact_phone,
+            self.contact_email,
+            self.comments,
+        )
+
+    @property
+    def count_sites(self):
+        return self.sites.count()
+
+
+#
 # Sites
 #
 
@@ -75,23 +146,17 @@ class SiteManager(NaturalOrderByManager):
 @python_2_unicode_compatible
 class Site(CreatedUpdatedModel, CustomFieldModel):
     """
-    A Site represents a geographic location within a network; typically a building or campus. The optional facility
-    field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
+    A Site represents a geographic location within a network; typically a building or campus.
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
     region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
     tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
-    facility = models.CharField(max_length=50, blank=True)
+    facility = models.ForeignKey('Facility', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     time_zone = TimeZoneField(blank=True)
     description = models.CharField(max_length=100, blank=True)
-    physical_address = models.CharField(max_length=200, blank=True)
-    shipping_address = models.CharField(max_length=200, blank=True)
-    contact_name = models.CharField(max_length=50, blank=True)
-    contact_phone = models.CharField(max_length=20, blank=True)
-    contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     images = GenericRelation(ImageAttachment)
@@ -99,8 +164,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
 
     csv_headers = [
-        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
-        'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'comments',
     ]
 
     class Meta:
@@ -119,15 +183,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
             self.get_status_display(),
             self.region.name if self.region else None,
             self.tenant.name if self.tenant else None,
-            self.facility,
+            self.facility.name if self.facility else None,
             self.asn,
             self.time_zone,
             self.description,
-            self.physical_address,
-            self.shipping_address,
-            self.contact_name,
-            self.contact_phone,
-            self.contact_email,
             self.comments,
         )
 

+ 24 - 1
netbox/dcim/tables.py

@@ -8,7 +8,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
-    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
+    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, Facility,
     VirtualChassis,
 )
 
@@ -30,6 +30,14 @@ SITE_REGION_LINK = """
 {% endif %}
 """
 
+SITE_FACILITY_LINK = """
+{% if record.facility %}
+    <a href="{% url 'dcim:site_list' %}?facility={{ record.facility.slug }}">{{ record.facility }}</a>
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 COLOR_LABEL = """
 <label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
 """
@@ -170,6 +178,20 @@ class RegionTable(BaseTable):
 
 
 #
+# Facilities
+#
+
+class FacilityTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
+
+    class Meta(BaseTable.Meta):
+        model = Facility
+        fields = ('pk', 'name', 'tenant', 'city', 'description')
+
+
+#
 # Sites
 #
 
@@ -179,6 +201,7 @@ class SiteTable(BaseTable):
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
+    facility = tables.TemplateColumn(template_code=SITE_FACILITY_LINK)
 
     class Meta(BaseTable.Meta):
         model = Site

+ 9 - 0
netbox/dcim/urls.py

@@ -18,6 +18,15 @@ urlpatterns = [
     url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
 
+    # Facilities
+    url(r'^facilities/$', views.FacilityListView.as_view(), name='facility_list'),
+    url(r'^facilities/add/$', views.FacilityCreateView.as_view(), name='facility_add'),
+    url(r'^facilities/import/$', views.FacilityBulkImportView.as_view(), name='facility_import'),
+    url(r'^facilities/edit/$', views.FacilityBulkEditView.as_view(), name='facility_bulk_edit'),
+    url(r'^facilities/(?P<slug>[\w-]+)/$', views.FacilityView.as_view(), name='facility'),
+    url(r'^facilities/(?P<slug>[\w-]+)/edit/$', views.FacilityEditView.as_view(), name='facility_edit'),
+    url(r'^facilities/(?P<slug>[\w-]+)/delete/$', views.FacilityDeleteView.as_view(), name='facility_delete'),
+
     # Sites
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'),

+ 66 - 4
netbox/dcim/views.py

@@ -33,7 +33,7 @@ from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    RackReservation, RackRole, Region, Site, Facility, VirtualChassis,
 )
 
 
@@ -162,11 +162,73 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 #
+# Facilities
+#
+
+class FacilityListView(ObjectListView):
+    queryset = Facility.objects.select_related('tenant')
+    filter = filters.FacilityFilter
+    filter_form = forms.FacilityFilterForm
+    table = tables.FacilityTable
+    template_name = 'dcim/facility_list.html'
+
+
+class FacilityView(View):
+
+    def get(self, request, slug):
+
+        facility = get_object_or_404(Facility.objects.select_related('tenant__group'), slug=slug)
+        stats = {
+            'site_count': Site.objects.filter(facility=facility).count(),
+        }
+
+        return render(request, 'dcim/facility.html', {
+            'facility': facility,
+            'stats': stats,
+        })
+
+
+class FacilityCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_facility'
+    model = Facility
+    model_form = forms.FacilityForm
+    template_name = 'dcim/facility_edit.html'
+    default_return_url = 'dcim:facility_list'
+
+
+class FacilityEditView(FacilityCreateView):
+    permission_required = 'dcim.change_facility'
+
+
+class FacilityDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_facility'
+    model = Facility
+    default_return_url = 'dcim:facility_list'
+
+
+class FacilityBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_facility'
+    model_form = forms.FacilityCSVForm
+    table = tables.FacilityTable
+    default_return_url = 'dcim:facility_list'
+
+
+class FacilityBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_facility'
+    cls = Facility
+    queryset = Facility.objects.select_related('tenant')
+    filter = filters.FacilityFilter
+    table = tables.FacilityTable
+    form = forms.FacilityBulkEditForm
+    default_return_url = 'dcim:facility_list'
+
+
+#
 # Sites
 #
 
 class SiteListView(ObjectListView):
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.select_related('region', 'tenant', 'facility')
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     table = tables.SiteTable
@@ -177,7 +239,7 @@ class SiteView(View):
 
     def get(self, request, slug):
 
-        site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug)
+        site = get_object_or_404(Site.objects.select_related('region', 'tenant__group', 'facility'), slug=slug)
         stats = {
             'rack_count': Rack.objects.filter(site=site).count(),
             'device_count': Device.objects.filter(site=site).count(),
@@ -227,7 +289,7 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_site'
     cls = Site
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.select_related('region', 'tenant', 'facility')
     filter = filters.SiteFilter
     table = tables.SiteTable
     form = forms.SiteBulkEditForm

+ 1 - 0
netbox/netbox/forms.py

@@ -11,6 +11,7 @@ OBJ_TYPE_CHOICES = (
         ('circuit', 'Circuits'),
     )),
     ('DCIM', (
+        ('facility', 'Facilities'),
         ('site', 'Sites'),
         ('rack', 'Racks'),
         ('devicetype', 'Device types'),

+ 11 - 4
netbox/netbox/views.py

@@ -12,9 +12,9 @@ from rest_framework.views import APIView
 from circuits.filters import CircuitFilter, ProviderFilter
 from circuits.models import Circuit, Provider
 from circuits.tables import CircuitTable, ProviderTable
-from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
-from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
-from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable
+from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, FacilityFilter
+from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, Facility
+from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, FacilityTable
 from extras.models import ReportResult, TopologyMap, UserAction
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -46,8 +46,14 @@ SEARCH_TYPES = OrderedDict((
         'url': 'circuits:circuit_list',
     }),
     # DCIM
+    ('facility', {
+        'queryset': Facility.objects.select_related('tenant'),
+        'filter': FacilityFilter,
+        'table': FacilityTable,
+        'url': 'dcim:facility_list',
+    }),
     ('site', {
-        'queryset': Site.objects.select_related('region', 'tenant'),
+        'queryset': Site.objects.select_related('region', 'tenant', 'facility'),
         'filter': SiteFilter,
         'table': SiteTable,
         'url': 'dcim:site_list',
@@ -143,6 +149,7 @@ class HomeView(View):
         stats = {
 
             # Organization
+            'facility_count': Facility.objects.count(),
             'site_count': Site.objects.count(),
             'tenant_count': Tenant.objects.count(),
 

+ 203 - 0
netbox/templates/dcim/facility.html

@@ -0,0 +1,203 @@
+{% extends '_base.html' %}
+{% load static from staticfiles %}
+{% load tz %}
+{% load helpers %}
+
+{% block content %}
+<div class="row">
+    <div class="col-sm-8 col-md-9">
+        <ol class="breadcrumb">
+            {% if site.region %}
+                {% 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>{{ facility }}</li>
+        </ol>
+    </div>
+    <div class="col-sm-4 col-md-3">
+        <form action="{% url 'dcim:facility_list' %}" method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" placeholder="Search facilities" />
+                <span class="input-group-btn">
+                    <button type="submit" class="btn btn-primary">
+                        <span class="fa fa-search" aria-hidden="true"></span>
+                    </button>
+                </span>
+            </div>
+        </form>
+    </div>
+</div>
+<div class="pull-right">
+    {% if perms.dcim.change_facility %}
+		<a href="{% url 'dcim:facility_edit' slug=facility.slug %}" class="btn btn-warning">
+			<span class="fa fa-pencil" aria-hidden="true"></span>
+			Edit this facility
+		</a>
+    {% endif %}
+    {% if perms.dcim.delete_facility %}
+		<a href="{% url 'dcim:facility_delete' slug=facility.slug %}" class="btn btn-danger">
+			<span class="fa fa-trash" aria-hidden="true"></span>
+			Delete this facility
+		</a>
+    {% endif %}
+</div>
+<h1>{% block title %}{{ facility }}{% endblock %}</h1>
+{% include 'inc/created_updated.html' with obj=facility %}
+<div class="row">
+	<div class="col-md-7">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Facility</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if facility.tenant %}
+                            {% if facility.tenant.group %}
+                                <a href="{{ facility.tenant.group.get_absolute_url }}">{{ facility.tenant.group }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
+                            <a href="{{ facility.tenant.get_absolute_url }}">{{ facility.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>
+                        {% if facility.description %}
+                            {{ facility.description }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>PeeringDB</td>
+                    <td>
+                        {% if facility.peeringdb_id %}
+                            <a href="https://peeringdb.com/fac/{{ facility.peeringdb_id }}" target="_blank">View on PeeringDB</a>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Location and contact</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>City</td>
+                    <td>
+                        {% if facility.city %}
+                            <span>{{ facility.city }}</span>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>GPS coordinates</td>
+                    <td>
+                        {% if facility.latitude and facility.longitude %}
+                            <div class="pull-right">
+                                <a href="https://www.openstreetmap.org/?mlat={{ facility.latitude }}&mlon={{ facility.longitude }}#map=19/{{ facility.latitude }}/{{ facility.longitude }}" target="_blank" class="btn btn-primary btn-xs">
+                                    <i class="glyphicon glyphicon-map-marker"></i> Map it
+                                </a>
+                            </div>
+                            <span><a href="geo:{{ facility.latitude }},{{ facility.longitude }}">{{ facility.latitude }}, {{ facility.longitude }}</span>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Physical Address</td>
+                    <td>
+                        {% if facility.physical_address %}
+                            <span>{{ facility.physical_address|linebreaksbr }}</span>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Shipping Address</td>
+                    <td>
+                        {% if facility.shipping_address %}
+                            <span>{{ facility.shipping_address|linebreaksbr }}</span>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Contact Name</td>
+                    <td>
+                        {% if facility.contact_name %}
+                            <span>{{ facility.contact_name }}</span>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Contact Phone</td>
+                    <td>
+                        {% if facility.contact_phone %}
+                            <a href="tel:{{ facility.contact_phone }}">{{ facility.contact_phone }}</a>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Contact E-Mail</td>
+                    <td>
+                        {% if facility.contact_email %}
+                            <a href="mailto:{{ facility.contact_email }}">{{ facility.contact_email }}</a>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
+        {% with facility.get_custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Comments</strong>
+            </div>
+            <div class="panel-body">
+                {% if facility.comments %}
+                    {{ facility.comments|gfm }}
+                {% else %}
+                    <span class="text-muted">None</span>
+                {% endif %}
+            </div>
+        </div>
+    </div>
+    <div class="col-md-5">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Stats</strong>
+            </div>
+            <div class="row panel-body">
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'dcim:site_list' %}?facility={{ facility.slug }}" class="btn {% if stats.site_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.site_count }}</a></h2>
+                    <p>Sites</p>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 48 - 0
netbox/templates/dcim/facility_edit.html

@@ -0,0 +1,48 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Facility</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.slug %}
+            {% render_field form.description %}
+            {% render_field form.peeringdb_id %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tenancy</strong></div>
+        <div class="panel-body">
+            {% render_field form.tenant_group %}
+            {% render_field form.tenant %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Location and contact</strong></div>
+        <div class="panel-body">
+            {% render_field form.city %}
+            {% render_field form.latitude %}
+            {% render_field form.longitude %}
+            {% render_field form.physical_address %}
+            {% render_field form.shipping_address %}
+            {% render_field form.contact_name %}
+            {% render_field form.contact_phone %}
+            {% render_field form.contact_email %}
+        </div>
+    </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Comments</strong></div>
+        <div class="panel-body">
+            {% render_field form.comments %}
+        </div>
+    </div>
+{% endblock %}

+ 21 - 0
netbox/templates/dcim/facility_list.html

@@ -0,0 +1,21 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.dcim.add_facility %}
+        {% add_button 'dcim:facility_add' %}
+        {% import_button 'dcim:facility_import' %}
+    {% endif %}
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Facilities{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:facility_bulk_edit' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -96,9 +96,9 @@
                     <td>Facility</td>
                     <td>
                         {% if site.facility %}
-                            {{ site.facility }}
+                            <a href="{{ site.facility.get_absolute_url }}">{{ site.facility }}</a>
                         {% else %}
-                            <span class="text-muted">N/A</span>
+                            <span class="text-muted">None</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -135,68 +135,6 @@
                 </tr>
             </table>
         </div>
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Contact Info</strong>
-            </div>
-            <table class="table table-hover panel-body attr-table">
-                <tr>
-                    <td>Physical Address</td>
-                    <td>
-                        {% if site.physical_address %}
-                            <div class="pull-right">
-                                <a href="http://maps.google.com/?q={{ site.physical_address|oneline }}" target="_blank" class="btn btn-primary btn-xs">
-                                    <i class="glyphicon glyphicon-map-marker"></i> Map it
-                                </a>
-                            </div>
-                            <span>{{ site.physical_address|linebreaksbr }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Shipping Address</td>
-                    <td>
-                        {% if site.shipping_address %}
-                            <span>{{ site.shipping_address|linebreaksbr }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Contact Name</td>
-                    <td>
-                        {% if site.contact_name %}
-                            <span>{{ site.contact_name }}</span>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Contact Phone</td>
-                    <td>
-                        {% if site.contact_phone %}
-                            <a href="tel:{{ site.contact_phone }}">{{ site.contact_phone }}</a>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Contact E-Mail</td>
-                    <td>
-                        {% if site.contact_email %}
-                            <a href="mailto:{{ site.contact_email }}">{{ site.contact_email }}</a>
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-            </table>
-        </div>
         {% with site.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}

+ 0 - 10
netbox/templates/dcim/site_edit.html

@@ -22,16 +22,6 @@
             {% render_field form.tenant %}
         </div>
     </div>
-    <div class="panel panel-default">
-        <div class="panel-heading"><strong>Contact Info</strong></div>
-        <div class="panel-body">
-            {% render_field form.physical_address %}
-            {% render_field form.shipping_address %}
-            {% render_field form.contact_name %}
-            {% render_field form.contact_phone %}
-            {% render_field form.contact_email %}
-        </div>
-    </div>
     {% if form.custom_fields %}
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 5 - 0
netbox/templates/home.html

@@ -10,6 +10,11 @@
             </div>
             <div class="list-group">
                 <div class="list-group-item">
+                    <span class="badge pull-right">{{ stats.facility_count }}</span>
+                    <h4 class="list-group-item-heading"><a href="{% url 'dcim:facility_list' %}">Facilities</a></h4>
+                    <p class="list-group-item-text text-muted">Shared buildings or data centers</p>
+                </div>
+                <div class="list-group-item">
                     <span class="badge pull-right">{{ stats.site_count }}</span>
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:site_list' %}">Sites</a></h4>
                     <p class="list-group-item-text text-muted">Geographic locations</p>

+ 10 - 1
netbox/templates/inc/nav_menu.html

@@ -16,11 +16,20 @@
         <div id="navbar" class="navbar-collapse collapse">
             {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
             <ul class="nav navbar-nav">
-                <li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/reports/' %} active{% endif %}">
+                <li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/facilities/,/dcim/regions/,/tenancy/,/extras/reports/' %} active{% endif %}">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Sites</li>
                         <li>
+                            {% if perms.dcim.add_facility %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'dcim:facility_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
+                                    <a href="{% url 'dcim:facility_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
+                            <a href="{% url 'dcim:facility_list' %}">Facilities</a>
+                        </li>
+                        <li>
                             {% if perms.dcim.add_site %}
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:site_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>

+ 4 - 0
netbox/templates/tenancy/tenant.html

@@ -93,6 +93,10 @@
             </div>
             <div class="row panel-body">
                 <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'dcim:facility_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.facility_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.facility_count }}</a></h2>
+                    <p>Facilities</p>
+                </div>
+                <div class="col-md-4 text-center">
                     <h2><a href="{% url 'dcim:site_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.site_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.site_count }}</a></h2>
                     <p>Sites</p>
                 </div>

+ 2 - 1
netbox/tenancy/views.py

@@ -7,7 +7,7 @@ from django.urls import reverse
 from django.views.generic import View
 
 from circuits.models import Circuit
-from dcim.models import Site, Rack, Device, RackReservation
+from dcim.models import Site, Facility, Rack, Device, RackReservation
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -74,6 +74,7 @@ class TenantView(View):
         tenant = get_object_or_404(Tenant, slug=slug)
         stats = {
             'site_count': Site.objects.filter(tenant=tenant).count(),
+            'facility_count': Facility.objects.filter(tenant=tenant).count(),
             'rack_count': Rack.objects.filter(tenant=tenant).count(),
             'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(),
             'device_count': Device.objects.filter(tenant=tenant).count(),