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,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     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 extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress, VLAN
 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
 # Sites
 #
 #
 
 
@@ -59,13 +94,14 @@ class SiteSerializer(CustomFieldModelSerializer):
     status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
     status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
     region = NestedRegionSerializer()
     region = NestedRegionSerializer()
     tenant = NestedTenantSerializer()
     tenant = NestedTenantSerializer()
+    facility = NestedFacilitySerializer()
     time_zone = TimeZoneField(required=False)
     time_zone = TimeZoneField(required=False)
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = [
         fields = [
             'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
             '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',
             'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
             'count_circuits',
             'count_circuits',
         ]
         ]
@@ -85,8 +121,7 @@ class WritableSiteSerializer(CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = [
         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',
             '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')
 router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice')
 
 
 # Sites
 # Sites
+router.register(r'facilities', views.FacilityViewSet)
 router.register(r'regions', views.RegionViewSet)
 router.register(r'regions', views.RegionViewSet)
 router.register(r'sites', views.SiteViewSet)
 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,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     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.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
 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
 # Sites
 #
 #
 
 
 class SiteViewSet(CustomFieldModelViewSet):
 class SiteViewSet(CustomFieldModelViewSet):
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.select_related('region', 'tenant', 'facility')
     serializer_class = serializers.SiteSerializer
     serializer_class = serializers.SiteSerializer
     write_serializer_class = serializers.WritableSiteSerializer
     write_serializer_class = serializers.WritableSiteSerializer
     filter_class = filters.SiteFilter
     filter_class = filters.SiteFilter

+ 53 - 8
netbox/dcim/filters.py

@@ -18,7 +18,7 @@ from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     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)
         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):
 class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -82,23 +123,27 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (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:
     class Meta:
         model = Site
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         qs_filter = (
         qs_filter = (
             Q(name__icontains=value) |
             Q(name__icontains=value) |
-            Q(facility__icontains=value) |
             Q(description__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)
             Q(comments__icontains=value)
         )
         )
         try:
         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,
     DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
     Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
-    RackRole, Region, Site, VirtualChassis
+    RackRole, Region, Site, Facility, VirtualChassis
 )
 )
 
 
 DEVICE_BY_PK_RE = '{\d+\}'
 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()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
 
 
     class Meta:
     class Meta:
-        model = Site
+        model = Facility
         fields = [
         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',
             'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
         ]
         ]
         widgets = {
         widgets = {
@@ -120,13 +119,72 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
         }
         }
         help_texts = {
         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",
             '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",
             'asn': "BGP autonomous system number",
             'time_zone': "Local time zone",
             'time_zone': "Local time zone",
             'description': "Short description (will appear in sites list)",
             '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.',
             '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:
     class Meta:
         model = Site
         model = Site
@@ -170,12 +237,13 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
     status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
     region = TreeNodeChoiceField(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)
+    facility = forms.ModelChoiceField(queryset=Facility.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')
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
     time_zone = TimeZoneFormField(required=False)
     time_zone = TimeZoneFormField(required=False)
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
+        nullable_fields = ['region', 'tenant', 'facility', 'asn', 'description', 'time_zone']
 
 
 
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -197,6 +265,11 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --'
         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
 # Sites
 #
 #
 
 
@@ -75,23 +146,17 @@ class SiteManager(NaturalOrderByManager):
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class Site(CreatedUpdatedModel, CustomFieldModel):
 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)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
     status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
     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)
     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)
     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')
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     time_zone = TimeZoneField(blank=True)
     time_zone = TimeZoneField(blank=True)
     description = models.CharField(max_length=100, 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)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     images = GenericRelation(ImageAttachment)
     images = GenericRelation(ImageAttachment)
@@ -99,8 +164,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
     objects = SiteManager()
 
 
     csv_headers = [
     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:
     class Meta:
@@ -119,15 +183,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
             self.get_status_display(),
             self.get_status_display(),
             self.region.name if self.region else None,
             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.name if self.facility else None,
             self.asn,
             self.asn,
             self.time_zone,
             self.time_zone,
             self.description,
             self.description,
-            self.physical_address,
-            self.shipping_address,
-            self.contact_name,
-            self.contact_phone,
-            self.contact_email,
             self.comments,
             self.comments,
         )
         )
 
 

+ 24 - 1
netbox/dcim/tables.py

@@ -8,7 +8,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
     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,
     VirtualChassis,
 )
 )
 
 
@@ -30,6 +30,14 @@ SITE_REGION_LINK = """
 {% endif %}
 {% 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 = """
 COLOR_LABEL = """
 <label class="label" style="background-color: #{{ record.color }}">{{ record }}</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
 # Sites
 #
 #
 
 
@@ -179,6 +201,7 @@ class SiteTable(BaseTable):
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
+    facility = tables.TemplateColumn(template_code=SITE_FACILITY_LINK)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Site
         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/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
     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
     # Sites
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'),
     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,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     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
 # Sites
 #
 #
 
 
 class SiteListView(ObjectListView):
 class SiteListView(ObjectListView):
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.select_related('region', 'tenant', 'facility')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     filter_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
@@ -177,7 +239,7 @@ class SiteView(View):
 
 
     def get(self, request, slug):
     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 = {
         stats = {
             'rack_count': Rack.objects.filter(site=site).count(),
             'rack_count': Rack.objects.filter(site=site).count(),
             'device_count': Device.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):
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_site'
     permission_required = 'dcim.change_site'
     cls = Site
     cls = Site
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.select_related('region', 'tenant', 'facility')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     table = tables.SiteTable
     table = tables.SiteTable
     form = forms.SiteBulkEditForm
     form = forms.SiteBulkEditForm

+ 1 - 0
netbox/netbox/forms.py

@@ -11,6 +11,7 @@ OBJ_TYPE_CHOICES = (
         ('circuit', 'Circuits'),
         ('circuit', 'Circuits'),
     )),
     )),
     ('DCIM', (
     ('DCIM', (
+        ('facility', 'Facilities'),
         ('site', 'Sites'),
         ('site', 'Sites'),
         ('rack', 'Racks'),
         ('rack', 'Racks'),
         ('devicetype', 'Device types'),
         ('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.filters import CircuitFilter, ProviderFilter
 from circuits.models import Circuit, Provider
 from circuits.models import Circuit, Provider
 from circuits.tables import CircuitTable, ProviderTable
 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 extras.models import ReportResult, TopologyMap, UserAction
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -46,8 +46,14 @@ SEARCH_TYPES = OrderedDict((
         'url': 'circuits:circuit_list',
         'url': 'circuits:circuit_list',
     }),
     }),
     # DCIM
     # DCIM
+    ('facility', {
+        'queryset': Facility.objects.select_related('tenant'),
+        'filter': FacilityFilter,
+        'table': FacilityTable,
+        'url': 'dcim:facility_list',
+    }),
     ('site', {
     ('site', {
-        'queryset': Site.objects.select_related('region', 'tenant'),
+        'queryset': Site.objects.select_related('region', 'tenant', 'facility'),
         'filter': SiteFilter,
         'filter': SiteFilter,
         'table': SiteTable,
         'table': SiteTable,
         'url': 'dcim:site_list',
         'url': 'dcim:site_list',
@@ -143,6 +149,7 @@ class HomeView(View):
         stats = {
         stats = {
 
 
             # Organization
             # Organization
+            'facility_count': Facility.objects.count(),
             'site_count': Site.objects.count(),
             'site_count': Site.objects.count(),
             'tenant_count': Tenant.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>Facility</td>
                     <td>
                     <td>
                         {% if site.facility %}
                         {% if site.facility %}
-                            {{ site.facility }}
+                            <a href="{{ site.facility.get_absolute_url }}">{{ site.facility }}</a>
                         {% else %}
                         {% else %}
-                            <span class="text-muted">N/A</span>
+                            <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
@@ -135,68 +135,6 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </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 %}
         {% with site.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% endwith %}

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

@@ -22,16 +22,6 @@
             {% render_field form.tenant %}
             {% render_field form.tenant %}
         </div>
         </div>
     </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 %}
     {% if form.custom_fields %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 5 - 0
netbox/templates/home.html

@@ -10,6 +10,11 @@
             </div>
             </div>
             <div class="list-group">
             <div class="list-group">
                 <div class="list-group-item">
                 <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>
                     <span class="badge pull-right">{{ stats.site_count }}</span>
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:site_list' %}">Sites</a></h4>
                     <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>
                     <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">
         <div id="navbar" class="navbar-collapse collapse">
             {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
             {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
             <ul class="nav navbar-nav">
             <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>
                     <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">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Sites</li>
                         <li class="dropdown-header">Sites</li>
                         <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 %}
                             {% if perms.dcim.add_site %}
                                 <div class="buttons pull-right">
                                 <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>
                                     <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>
             <div class="row panel-body">
             <div class="row panel-body">
                 <div class="col-md-4 text-center">
                 <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>
                     <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>
                     <p>Sites</p>
                 </div>
                 </div>

+ 2 - 1
netbox/tenancy/views.py

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