Просмотр исходного кода

Merge pull request #408 from digitalocean/develop

Release v1.4.0
Jeremy Stretch 9 лет назад
Родитель
Сommit
9889e120bd
100 измененных файлов с 1744 добавлено и 641 удалено
  1. 9 0
      docs/data-model/tenancy.md
  2. 12 0
      docs/installation/netbox.md
  3. 1 0
      mkdocs.yml
  4. 4 3
      netbox/circuits/admin.py
  5. 4 2
      netbox/circuits/api/serializers.py
  6. 2 2
      netbox/circuits/api/views.py
  7. 20 4
      netbox/circuits/filters.py
  8. 26 0
      netbox/circuits/fixtures/initial_data.json
  9. 15 3
      netbox/circuits/forms.py
  10. 22 0
      netbox/circuits/migrations/0004_circuit_add_tenant.py
  11. 3 0
      netbox/circuits/models.py
  12. 9 6
      netbox/circuits/tables.py
  13. 8 1
      netbox/circuits/views.py
  14. 10 6
      netbox/dcim/api/serializers.py
  15. 11 9
      netbox/dcim/api/views.py
  16. 40 7
      netbox/dcim/filters.py
  17. 201 0
      netbox/dcim/fixtures/initial_data.json
  18. 60 12
      netbox/dcim/forms.py
  19. 32 0
      netbox/dcim/migrations/0012_site_rack_device_add_tenant.py
  20. 7 0
      netbox/dcim/models.py
  21. 34 24
      netbox/dcim/tables.py
  22. 6 0
      netbox/dcim/tests/test_apis.py
  23. 1 0
      netbox/dcim/urls.py
  24. 42 10
      netbox/dcim/views.py
  25. 11 6
      netbox/ipam/admin.py
  26. 20 6
      netbox/ipam/api/serializers.py
  27. 10 10
      netbox/ipam/api/views.py
  28. 123 8
      netbox/ipam/filters.py
  29. 125 0
      netbox/ipam/fixtures/initial_data.json
  30. 70 25
      netbox/ipam/forms.py
  31. 27 0
      netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py
  32. 27 0
      netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py
  33. 10 2
      netbox/ipam/models.py
  34. 38 18
      netbox/ipam/tables.py
  35. 30 16
      netbox/ipam/views.py
  36. 2 1
      netbox/netbox/settings.py
  37. 2 0
      netbox/netbox/urls.py
  38. 7 2
      netbox/netbox/views.py
  39. 12 0
      netbox/secrets/filters.py
  40. 42 0
      netbox/secrets/fixtures/initial_data.json
  41. 5 4
      netbox/secrets/tables.py
  42. 1 1
      netbox/templates/500.html
  43. 79 70
      netbox/templates/_base.html
  44. 80 29
      netbox/templates/circuits/circuit.html
  45. 8 2
      netbox/templates/circuits/circuit_edit.html
  46. 6 1
      netbox/templates/circuits/circuit_import.html
  47. 2 18
      netbox/templates/circuits/circuit_list.html
  48. 1 1
      netbox/templates/circuits/circuittype_list.html
  49. 55 13
      netbox/templates/circuits/provider.html
  50. 2 18
      netbox/templates/circuits/provider_list.html
  51. 1 1
      netbox/templates/dcim/console_connections_list.html
  52. 15 5
      netbox/templates/dcim/device.html
  53. 1 0
      netbox/templates/dcim/device_bulk_edit.html
  54. 1 0
      netbox/templates/dcim/device_edit.html
  55. 6 1
      netbox/templates/dcim/device_import.html
  56. 1 1
      netbox/templates/dcim/device_inventory.html
  57. 3 20
      netbox/templates/dcim/device_list.html
  58. 1 1
      netbox/templates/dcim/devicerole_list.html
  59. 2 2
      netbox/templates/dcim/devicetype.html
  60. 1 1
      netbox/templates/dcim/devicetype_list.html
  61. 2 2
      netbox/templates/dcim/inc/_device_header.html
  62. 6 4
      netbox/templates/dcim/inc/_interface.html
  63. 1 1
      netbox/templates/dcim/interface_connections_list.html
  64. 1 1
      netbox/templates/dcim/manufacturer_list.html
  65. 1 1
      netbox/templates/dcim/platform_list.html
  66. 1 1
      netbox/templates/dcim/power_connections_list.html
  67. 19 9
      netbox/templates/dcim/rack.html
  68. 1 0
      netbox/templates/dcim/rack_bulk_edit.html
  69. 1 0
      netbox/templates/dcim/rack_edit.html
  70. 6 1
      netbox/templates/dcim/rack_import.html
  71. 3 20
      netbox/templates/dcim/rack_list.html
  72. 1 1
      netbox/templates/dcim/rackgroup_list.html
  73. 63 47
      netbox/templates/dcim/site.html
  74. 13 0
      netbox/templates/dcim/site_bulk_edit.html
  75. 1 0
      netbox/templates/dcim/site_edit.html
  76. 6 1
      netbox/templates/dcim/site_import.html
  77. 8 21
      netbox/templates/dcim/site_list.html
  78. 39 22
      netbox/templates/home.html
  79. 1 1
      netbox/templates/import_success.html
  80. 2 2
      netbox/templates/inc/export_button.html
  81. 2 2
      netbox/templates/inc/filter_panel.html
  82. 18 0
      netbox/templates/inc/search_panel.html
  83. 18 6
      netbox/templates/ipam/aggregate.html
  84. 2 1
      netbox/templates/ipam/aggregate_list.html
  85. 8 10
      netbox/templates/ipam/inc/prefix_header.html
  86. 24 12
      netbox/templates/ipam/ipaddress.html
  87. 2 1
      netbox/templates/ipam/ipaddress_bulk_edit.html
  88. 1 0
      netbox/templates/ipam/ipaddress_edit.html
  89. 6 1
      netbox/templates/ipam/ipaddress_import.html
  90. 3 20
      netbox/templates/ipam/ipaddress_list.html
  91. 25 6
      netbox/templates/ipam/prefix.html
  92. 1 0
      netbox/templates/ipam/prefix_bulk_edit.html
  93. 6 1
      netbox/templates/ipam/prefix_import.html
  94. 3 20
      netbox/templates/ipam/prefix_list.html
  95. 1 1
      netbox/templates/ipam/rir_list.html
  96. 1 1
      netbox/templates/ipam/role_list.html
  97. 30 30
      netbox/templates/ipam/vlan.html
  98. 2 1
      netbox/templates/ipam/vlan_bulk_edit.html
  99. 6 1
      netbox/templates/ipam/vlan_import.html
  100. 3 20
      netbox/templates/ipam/vlan_list.html

+ 9 - 0
docs/data-model/tenancy.md

@@ -0,0 +1,9 @@
+NetBox supports the concept of individual tenants within its parent organization. Typically, these are used to represent individual customers or internal departments.
+
+# Tenants
+
+A tenant represents a discrete organization. Certain resources within NetBox can be assigned to a tenant. This makes it very convenient to track which resources are assigned to which customers, for instance.
+
+### Tenant Groups
+
+Tenants are grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions."

+ 12 - 0
docs/installation/netbox.md

@@ -163,6 +163,18 @@ Are you sure you want to do this?
 Type 'yes' to continue, or 'no' to cancel: yes
 ```
 
+# Load Initial Data (Optional)
+
+NetBox ships with some initial data to help you get started: RIR definitions, common devices roles, etc. You can delete any seed data that you don't want to keep.
+
+!!! note
+    This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
+
+```
+# ./manage.py loaddata initial_data
+Installed 43 object(s) from 4 fixture(s)
+```
+
 # Test the Application
 
 At this point, NetBox should be able to run. We can verify this by starting a development instance:

+ 1 - 0
mkdocs.yml

@@ -17,6 +17,7 @@ pages:
         - 'DCIM': 'data-model/dcim.md'
         - 'IPAM': 'data-model/ipam.md'
         - 'Secrets': 'data-model/secrets.md'
+        - 'Tenancy': 'data-model/tenancy.md'
         - 'Extras': 'data-model/extras.md'
     - 'API Integration': 'api-integration.md'
 

+ 4 - 3
netbox/circuits/admin.py

@@ -21,10 +21,11 @@ class CircuitTypeAdmin(admin.ModelAdmin):
 
 @admin.register(Circuit)
 class CircuitAdmin(admin.ModelAdmin):
-    list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id']
-    list_filter = ['provider']
+    list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
+                    'xconnect_id']
+    list_filter = ['provider', 'type', 'tenant']
     exclude = ['interface']
 
     def get_queryset(self, request):
         qs = super(CircuitAdmin, self).get_queryset(request)
-        return qs.select_related('provider', 'type', 'site')
+        return qs.select_related('provider', 'type', 'tenant', 'site')

+ 4 - 2
netbox/circuits/api/serializers.py

@@ -2,6 +2,7 @@ from rest_framework import serializers
 
 from circuits.models import Provider, CircuitType, Circuit
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
+from tenancy.api.serializers import TenantNestedSerializer
 
 
 #
@@ -45,13 +46,14 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
 class CircuitSerializer(serializers.ModelSerializer):
     provider = ProviderNestedSerializer()
     type = CircuitTypeNestedSerializer()
+    tenant = TenantNestedSerializer()
     site = SiteNestedSerializer()
     interface = InterfaceNestedSerializer()
 
     class Meta:
         model = Circuit
-        fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate',
-                  'xconnect_id', 'comments']
+        fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
+                  'commit_rate', 'xconnect_id', 'comments']
 
 
 class CircuitNestedSerializer(CircuitSerializer):

+ 2 - 2
netbox/circuits/api/views.py

@@ -42,7 +42,7 @@ class CircuitListView(generics.ListAPIView):
     """
     List circuits (filterable)
     """
-    queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
     serializer_class = serializers.CircuitSerializer
     filter_class = CircuitFilter
 
@@ -51,5 +51,5 @@ class CircuitDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single circuit
     """
-    queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
     serializer_class = serializers.CircuitSerializer

+ 20 - 4
netbox/circuits/filters.py

@@ -3,6 +3,7 @@ import django_filters
 from django.db.models import Q
 
 from dcim.models import Site
+from tenancy.models import Tenant
 from .models import Provider, Circuit, CircuitType
 
 
@@ -28,10 +29,10 @@ class ProviderFilter(django_filters.FilterSet):
         fields = ['q', 'name', 'account', 'asn']
 
     def search(self, queryset, value):
-        value = value.strip()
         return queryset.filter(
             Q(name__icontains=value) |
-            Q(account__icontains=value)
+            Q(account__icontains=value) |
+            Q(comments__icontains=value)
         )
 
 
@@ -62,6 +63,17 @@ class CircuitFilter(django_filters.FilterSet):
         to_field_name='slug',
         label='Circuit type (slug)',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
@@ -79,5 +91,9 @@ class CircuitFilter(django_filters.FilterSet):
         fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
 
     def search(self, queryset, value):
-        value = value.strip()
-        return queryset.filter(cid__icontains=value)
+        return queryset.filter(
+            Q(cid__icontains=value) |
+            Q(xconnect_id__icontains=value) |
+            Q(pp_info__icontains=value) |
+            Q(comments__icontains=value)
+        )

+ 26 - 0
netbox/circuits/fixtures/initial_data.json

@@ -0,0 +1,26 @@
+[
+{
+    "model": "circuits.circuittype",
+    "pk": 1,
+    "fields": {
+        "name": "Internet",
+        "slug": "internet"
+    }
+},
+{
+    "model": "circuits.circuittype",
+    "pk": 2,
+    "fields": {
+        "name": "Private WAN",
+        "slug": "private-wan"
+    }
+},
+{
+    "model": "circuits.circuittype",
+    "pk": 3,
+    "fields": {
+        "name": "Out-of-Band",
+        "slug": "out-of-band"
+    }
+}
+]

+ 15 - 3
netbox/circuits/forms.py

@@ -2,6 +2,8 @@ from django import forms
 from django.db.models import Count
 
 from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
+from tenancy.forms import bulkedit_tenant_choices
+from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
 )
@@ -99,7 +101,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
     class Meta:
         model = Circuit
         fields = [
-            'cid', 'type', 'provider', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
+            'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
             'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
         ]
         help_texts = {
@@ -160,13 +162,15 @@ class CircuitFromCSVForm(forms.ModelForm):
                                       error_messages={'invalid_choice': 'Provider not found.'})
     type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Invalid circuit type.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
 
     class Meta:
         model = Circuit
-        fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id',
-                  'pp_info']
+        fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
+                  'xconnect_id', 'pp_info']
 
 
 class CircuitImportForm(BulkImportForm, BootstrapMixin):
@@ -177,6 +181,7 @@ class CircuitBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
     type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
     provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
     commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
     comments = CommentField()
@@ -192,6 +197,11 @@ def circuit_provider_choices():
     return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
 
 
+def circuit_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
+
+
 def circuit_site_choices():
     site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
     return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
@@ -201,5 +211,7 @@ class CircuitFilterForm(forms.Form, BootstrapMixin):
     type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
     provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
     site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))

+ 22 - 0
netbox/circuits/migrations/0004_circuit_add_tenant.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-26 21:59
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_initial'),
+        ('circuits', '0003_provider_32bit_asn_support'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant'),
+        ),
+    ]

+ 3 - 0
netbox/circuits/models.py

@@ -3,6 +3,7 @@ from django.db import models
 
 from dcim.fields import ASNField
 from dcim.models import Site, Interface
+from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 
 
@@ -66,6 +67,7 @@ class Circuit(CreatedUpdatedModel):
     cid = models.CharField(max_length=50, verbose_name='Circuit ID')
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
+    tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
     site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
     interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
@@ -90,6 +92,7 @@ class Circuit(CreatedUpdatedModel):
             self.cid,
             self.provider.name,
             self.type.name,
+            self.tenant.name if self.tenant else '',
             self.site.name,
             self.install_date.isoformat() if self.install_date else '',
             str(self.port_speed),

+ 9 - 6
netbox/circuits/tables.py

@@ -6,9 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 
 
-CIRCUITTYPE_EDIT_LINK = """
+CIRCUITTYPE_ACTIONS = """
 {% if perms.circuit.change_circuittype %}
-    <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}">Edit</a>
+    <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -21,11 +21,12 @@ class ProviderTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name')
     asn = tables.Column(verbose_name='ASN')
+    account = tables.Column(verbose_name='Account')
     circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
 
     class Meta(BaseTable.Meta):
         model = Provider
-        fields = ('pk', 'name', 'asn', 'circuit_count')
+        fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
 
 #
@@ -37,11 +38,12 @@ class CircuitTypeTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     circuit_count = tables.Column(verbose_name='Circuits')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=CIRCUITTYPE_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}},
+                                    verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = CircuitType
-        fields = ('pk', 'name', 'circuit_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'circuit_count', 'slug', 'actions')
 
 
 #
@@ -53,10 +55,11 @@ class CircuitTable(BaseTable):
     cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
     type = tables.Column(verbose_name='Type')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     port_speed_human = tables.Column(verbose_name='Port Speed')
     commit_rate_human = tables.Column(verbose_name='Commit Rate')
 
     class Meta(BaseTable.Meta):
         model = Circuit
-        fields = ('pk', 'cid', 'type', 'provider', 'site', 'port_speed_human', 'commit_rate_human')
+        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed_human', 'commit_rate_human')

+ 8 - 1
netbox/circuits/views.py

@@ -2,6 +2,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.db.models import Count
 from django.shortcuts import get_object_or_404, render
 
+from extras.models import Graph, GRAPH_TYPE_PROVIDER
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
@@ -27,10 +28,12 @@ def provider(request, slug):
 
     provider = get_object_or_404(Provider, slug=slug)
     circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
+    show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
 
     return render(request, 'circuits/provider.html', {
         'provider': provider,
         'circuits': circuits,
+        'show_graphs': show_graphs,
     })
 
 
@@ -109,7 +112,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class CircuitListView(ObjectListView):
-    queryset = Circuit.objects.select_related('provider', 'type', 'site')
+    queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable
@@ -159,6 +162,10 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
+        if form.cleaned_data['tenant'] == 0:
+            fields_to_update['tenant'] = None
+        elif form.cleaned_data['tenant']:
+            fields_to_update['tenant'] = form.cleaned_data['tenant']
         for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]

+ 10 - 6
netbox/dcim/api/serializers.py

@@ -6,6 +6,7 @@ from dcim.models import (
     DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
     PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
 )
+from tenancy.api.serializers import TenantNestedSerializer
 
 
 #
@@ -13,10 +14,11 @@ from dcim.models import (
 #
 
 class SiteSerializer(serializers.ModelSerializer):
+    tenant = TenantNestedSerializer()
 
     class Meta:
         model = Site
-        fields = ['id', 'name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
+        fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
                   'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
 
 
@@ -52,10 +54,11 @@ class RackGroupNestedSerializer(RackGroupSerializer):
 class RackSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     group = RackGroupNestedSerializer()
+    tenant = TenantNestedSerializer()
 
     class Meta:
         model = Rack
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments']
+        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments']
 
 
 class RackNestedSerializer(RackSerializer):
@@ -69,8 +72,8 @@ class RackDetailSerializer(RackSerializer):
     rear_units = serializers.SerializerMethodField()
 
     class Meta(RackSerializer.Meta):
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units',
-                  'rear_units']
+        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments',
+                  'front_units', 'rear_units']
 
     def get_front_units(self, obj):
         units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -218,6 +221,7 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
 class DeviceSerializer(serializers.ModelSerializer):
     device_type = DeviceTypeNestedSerializer()
     device_role = DeviceRoleNestedSerializer()
+    tenant = TenantNestedSerializer()
     platform = PlatformNestedSerializer()
     rack = RackNestedSerializer()
     primary_ip = DeviceIPAddressNestedSerializer()
@@ -227,8 +231,8 @@ class DeviceSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Device
-        fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
-                  'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
+        fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'rack',
+                  'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
 
     def get_parent_device(self, obj):
         try:

+ 11 - 9
netbox/dcim/api/views.py

@@ -27,7 +27,7 @@ class SiteListView(generics.ListAPIView):
     """
     List all sites
     """
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     serializer_class = serializers.SiteSerializer
 
 
@@ -35,7 +35,7 @@ class SiteDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single site
     """
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     serializer_class = serializers.SiteSerializer
 
 
@@ -47,7 +47,7 @@ class RackGroupListView(generics.ListAPIView):
     """
     List all rack groups
     """
-    queryset = RackGroup.objects.all()
+    queryset = RackGroup.objects.select_related('site')
     serializer_class = serializers.RackGroupSerializer
     filter_class = filters.RackGroupFilter
 
@@ -56,7 +56,7 @@ class RackGroupDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single rack group
     """
-    queryset = RackGroup.objects.all()
+    queryset = RackGroup.objects.select_related('site')
     serializer_class = serializers.RackGroupSerializer
 
 
@@ -68,7 +68,7 @@ class RackListView(generics.ListAPIView):
     """
     List racks (filterable)
     """
-    queryset = Rack.objects.select_related('site')
+    queryset = Rack.objects.select_related('site', 'group', 'tenant')
     serializer_class = serializers.RackSerializer
     filter_class = filters.RackFilter
 
@@ -77,7 +77,7 @@ class RackDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single rack
     """
-    queryset = Rack.objects.select_related('site')
+    queryset = Rack.objects.select_related('site', 'group', 'tenant')
     serializer_class = serializers.RackDetailSerializer
 
 
@@ -193,8 +193,9 @@ class DeviceListView(generics.ListAPIView):
     """
     List devices (filterable)
     """
-    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\
-        .prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside')
+    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
+                                             'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside',
+                                                                                          'primary_ip6__nat_outside')
     serializer_class = serializers.DeviceSerializer
     filter_class = filters.DeviceFilter
     renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
@@ -204,7 +205,8 @@ class DeviceDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single device
     """
-    queryset = Device.objects.all()
+    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
+                                             'rack__site', 'parent_bay')
     serializer_class = serializers.DeviceSerializer
 
 

+ 40 - 7
netbox/dcim/filters.py

@@ -6,6 +6,7 @@ from .models import (
     ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
     Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
 )
+from tenancy.models import Tenant
 
 
 class SiteFilter(django_filters.FilterSet):
@@ -13,17 +14,27 @@ class SiteFilter(django_filters.FilterSet):
         action='search',
         label='Search',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
 
     class Meta:
         model = Site
         fields = ['q', 'name', 'facility', 'asn']
 
     def search(self, queryset, value):
-        value = value.strip()
         qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \
-            Q(shipping_address__icontains=value)
+            Q(shipping_address__icontains=value) | Q(comments__icontains=value)
         try:
-            qs_filter |= Q(asn=int(value))
+            qs_filter |= Q(asn=int(value.strip()))
         except ValueError:
             pass
         return queryset.filter(qs_filter)
@@ -74,16 +85,27 @@ class RackFilter(django_filters.FilterSet):
         to_field_name='slug',
         label='Group',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
 
     class Meta:
         model = Rack
         fields = ['q', 'site_id', 'site', 'u_height']
 
     def search(self, queryset, value):
-        value = value.strip()
         return queryset.filter(
             Q(name__icontains=value) |
-            Q(facility_id__icontains=value)
+            Q(facility_id__icontains=value) |
+            Q(comments__icontains=value)
         )
 
 
@@ -143,6 +165,17 @@ class DeviceFilter(django_filters.FilterSet):
         to_field_name='slug',
         label='Role (slug)',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
     device_type_id = django_filters.ModelMultipleChoiceFilter(
         name='device_type',
         queryset=DeviceType.objects.all(),
@@ -200,11 +233,11 @@ class DeviceFilter(django_filters.FilterSet):
                   'is_network_device']
 
     def search(self, queryset, value):
-        value = value.strip()
         return queryset.filter(
             Q(name__icontains=value) |
             Q(serial__icontains=value) |
-            Q(modules__serial__icontains=value)
+            Q(modules__serial__icontains=value) |
+            Q(comments__icontains=value)
         ).distinct()
 
 

+ 201 - 0
netbox/dcim/fixtures/initial_data.json

@@ -0,0 +1,201 @@
+[
+{
+    "model": "dcim.devicerole",
+    "pk": 1,
+    "fields": {
+        "name": "Console Server",
+        "slug": "console-server",
+        "color": "teal"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 2,
+    "fields": {
+        "name": "Core Switch",
+        "slug": "core-switch",
+        "color": "blue"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 3,
+    "fields": {
+        "name": "Distribution Switch",
+        "slug": "distribution-switch",
+        "color": "blue"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 4,
+    "fields": {
+        "name": "Access Switch",
+        "slug": "access-switch",
+        "color": "blue"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 5,
+    "fields": {
+        "name": "Management Switch",
+        "slug": "management-switch",
+        "color": "orange"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 6,
+    "fields": {
+        "name": "Firewall",
+        "slug": "firewall",
+        "color": "red"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 7,
+    "fields": {
+        "name": "Router",
+        "slug": "router",
+        "color": "purple"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 8,
+    "fields": {
+        "name": "Server",
+        "slug": "server",
+        "color": "medium_gray"
+    }
+},
+{
+    "model": "dcim.devicerole",
+    "pk": 9,
+    "fields": {
+        "name": "PDU",
+        "slug": "pdu",
+        "color": "dark_gray"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 1,
+    "fields": {
+        "name": "APC",
+        "slug": "apc"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 2,
+    "fields": {
+        "name": "Cisco",
+        "slug": "cisco"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 3,
+    "fields": {
+        "name": "Dell",
+        "slug": "dell"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 4,
+    "fields": {
+        "name": "HP",
+        "slug": "hp"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 5,
+    "fields": {
+        "name": "Juniper",
+        "slug": "juniper"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 6,
+    "fields": {
+        "name": "Arista",
+        "slug": "arista"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 7,
+    "fields": {
+        "name": "Opengear",
+        "slug": "opengear"
+    }
+},
+{
+    "model": "dcim.manufacturer",
+    "pk": 8,
+    "fields": {
+        "name": "Super Micro",
+        "slug": "super-micro"
+    }
+},
+{
+    "model": "dcim.platform",
+    "pk": 1,
+    "fields": {
+        "name": "Cisco IOS",
+        "slug": "cisco-ios",
+        "rpc_client": "cisco-ios"
+    }
+},
+{
+    "model": "dcim.platform",
+    "pk": 2,
+    "fields": {
+        "name": "Cisco NX-OS",
+        "slug": "cisco-nx-os",
+        "rpc_client": ""
+    }
+},
+{
+    "model": "dcim.platform",
+    "pk": 3,
+    "fields": {
+        "name": "Juniper Junos",
+        "slug": "juniper-junos",
+        "rpc_client": "juniper-junos"
+    }
+},
+{
+    "model": "dcim.platform",
+    "pk": 4,
+    "fields": {
+        "name": "Arista EOS",
+        "slug": "arista-eos",
+        "rpc_client": ""
+    }
+},
+{
+    "model": "dcim.platform",
+    "pk": 5,
+    "fields": {
+        "name": "Linux",
+        "slug": "linux",
+        "rpc_client": ""
+    }
+},
+{
+    "model": "dcim.platform",
+    "pk": 6,
+    "fields": {
+        "name": "Opengear",
+        "slug": "opengear",
+        "rpc_client": "opengear"
+    }
+}
+]

+ 60 - 12
netbox/dcim/forms.py

@@ -4,6 +4,8 @@ from django import forms
 from django.db.models import Count, Q
 
 from ipam.models import IPAddress
+from tenancy.forms import bulkedit_tenant_choices
+from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
     FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
@@ -38,6 +40,15 @@ def get_device_by_name_or_pk(name):
     return device
 
 
+def bulkedit_platform_choices():
+    choices = [
+        (None, '---------'),
+        (0, 'None'),
+    ]
+    choices += [(p.pk, p.name) for p in Platform.objects.all()]
+    return choices
+
+
 #
 # Sites
 #
@@ -48,7 +59,7 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Site
-        fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
@@ -63,16 +74,33 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
 
 
 class SiteFromCSVForm(forms.ModelForm):
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
 
     class Meta:
         model = Site
-        fields = ['name', 'slug', 'facility', 'asn']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn']
 
 
 class SiteImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=SiteFromCSVForm)
 
 
+class SiteBulkEditForm(forms.Form, BootstrapMixin):
+    pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+
+
+def site_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
+
+
+class SiteFilterForm(forms.Form, BootstrapMixin):
+    tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
+
+
 #
 # Rack groups
 #
@@ -107,7 +135,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Rack
-        fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments']
+        fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments']
         help_texts = {
             'site': "The site at which the rack exists",
             'name': "Organizational rack name",
@@ -135,10 +163,12 @@ class RackFromCSVForm(forms.ModelForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
     group_name = forms.CharField(required=False)
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
 
     class Meta:
         model = Rack
-        fields = ['site', 'group_name', 'name', 'facility_id', 'u_height']
+        fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height']
 
     def clean(self):
 
@@ -161,6 +191,7 @@ class RackBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     u_height = forms.IntegerField(required=False, label='Height (U)')
     comments = CommentField()
 
@@ -175,11 +206,18 @@ def rack_group_choices():
     return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
 
 
+def rack_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
+
+
 class RackFilterForm(forms.Form, BootstrapMixin):
     site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
     group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
 
 
 #
@@ -203,8 +241,8 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = DeviceType
-        fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
-                  'is_network_device', 'subdevice_role']
+        fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
+                  'is_pdu', 'is_network_device', 'subdevice_role']
 
 
 class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
@@ -324,7 +362,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Device
-        fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
+        fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
                   'platform', 'primary_ip4', 'primary_ip6', 'comments']
         help_texts = {
             'device_role': "The function this device serves",
@@ -410,6 +448,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
 class BaseDeviceFromCSVForm(forms.ModelForm):
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
                                          error_messages={'invalid_choice': 'Invalid device role.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
                                           error_messages={'invalid_choice': 'Invalid manufacturer.'})
     model_name = forms.CharField()
@@ -441,8 +481,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
     face = forms.CharField(required=False)
 
     class Meta(BaseDeviceFromCSVForm.Meta):
-        fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
-                  'position', 'face']
+        fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'site',
+                  'rack_name', 'position', 'face']
 
     def clean(self):
 
@@ -477,7 +517,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
     device_bay_name = forms.CharField(required=False)
 
     class Meta(BaseDeviceFromCSVForm.Meta):
-        fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
+        fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
                   'device_bay_name']
 
     def clean(self):
@@ -512,8 +552,9 @@ class DeviceBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
-    platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform')
-    platform_delete = forms.BooleanField(required=False, label='Set platform to "none"')
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
+    platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False,
+                                      label='Platform')
     status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
     serial = forms.CharField(max_length=50, required=False, label='Serial Number')
 
@@ -533,6 +574,11 @@ def device_role_choices():
     return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
 
 
+def device_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
+
+
 def device_type_choices():
     type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
     return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
@@ -550,6 +596,8 @@ class DeviceFilterForm(forms.Form, BootstrapMixin):
                                               widget=forms.SelectMultiple(attrs={'size': 8}))
     role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
     device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
                                                widget=forms.SelectMultiple(attrs={'size': 8}))
     platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)

+ 32 - 0
netbox/dcim/migrations/0012_site_rack_device_add_tenant.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-26 21:59
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_initial'),
+        ('dcim', '0011_devicetype_part_number'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='tenancy.Tenant'),
+        ),
+    ]

+ 7 - 0
netbox/dcim/models.py

@@ -8,6 +8,7 @@ from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 
 from extras.rpc import RPC_CLIENTS
+from tenancy.models import Tenant
 from utilities.fields import NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
@@ -152,6 +153,7 @@ class Site(CreatedUpdatedModel):
     """
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
+    tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT)
     facility = models.CharField(max_length=50, blank=True)
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     physical_address = models.CharField(max_length=200, blank=True)
@@ -173,6 +175,7 @@ class Site(CreatedUpdatedModel):
         return ','.join([
             self.name,
             self.slug,
+            self.tenant.name if self.tenant else '',
             self.facility,
             str(self.asn),
         ])
@@ -237,6 +240,7 @@ class Rack(CreatedUpdatedModel):
     facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
     site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
     group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
+    tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
     u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
     comments = models.TextField(blank=True)
 
@@ -272,6 +276,7 @@ class Rack(CreatedUpdatedModel):
             self.group.name if self.group else '',
             self.name,
             self.facility_id or '',
+            self.tenant.name if self.tenant else '',
             str(self.u_height),
         ])
 
@@ -631,6 +636,7 @@ class Device(CreatedUpdatedModel):
     """
     device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
     device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
+    tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT)
     platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
     name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
     serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
@@ -724,6 +730,7 @@ class Device(CreatedUpdatedModel):
         return ','.join([
             self.name or '',
             self.device_role.name,
+            self.tenant.name if self.tenant else '',
             self.device_type.manufacturer.name,
             self.device_type.model,
             self.platform.name if self.platform else '',

+ 34 - 24
netbox/dcim/tables.py

@@ -16,27 +16,27 @@ DEVICE_LINK = """
 </a>
 """
 
-RACKGROUP_EDIT_LINK = """
+RACKGROUP_ACTIONS = """
 {% if perms.dcim.change_rackgroup %}
-    <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}">Edit</a>
+    <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
-DEVICEROLE_EDIT_LINK = """
+DEVICEROLE_ACTIONS = """
 {% if perms.dcim.change_devicerole %}
-    <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}">Edit</a>
+    <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
-MANUFACTURER_EDIT_LINK = """
+MANUFACTURER_ACTIONS = """
 {% if perms.dcim.change_manufacturer %}
-    <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}">Edit</a>
+    <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
-PLATFORM_EDIT_LINK = """
+PLATFORM_ACTIONS = """
 {% if perms.dcim.change_platform %}
-    <a href="{% url 'dcim:platform_edit' slug=record.slug %}">Edit</a>
+    <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -59,8 +59,10 @@ UTILIZATION_GRAPH = """
 #
 
 class SiteTable(BaseTable):
+    pk = ToggleColumn()
     name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
     facility = tables.Column(verbose_name='Facility')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     asn = tables.Column(verbose_name='ASN')
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
@@ -70,8 +72,8 @@ class SiteTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Site
-        fields = ('name', 'facility', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count',
-                  'circuit_count')
+        fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
+                  'vlan_count', 'circuit_count')
 
 
 #
@@ -84,11 +86,12 @@ class RackGroupTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     rack_count = tables.Column(verbose_name='Racks')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=RACKGROUP_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
+                                    verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = RackGroup
-        fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'actions')
 
 
 #
@@ -101,14 +104,16 @@ class RackTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
-    u_height = tables.Column(verbose_name='Height (U)')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
+    u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
-    u_consumed = tables.Column(accessor=Accessor('u_consumed'), verbose_name='Used (U)')
+    u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
     utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
     class Meta(BaseTable.Meta):
         model = Rack
-        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'u_height', 'devices', 'u_consumed', 'utilization')
+        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'u_height', 'devices', 'u_consumed',
+                  'utilization')
 
 
 class RackImportTable(BaseTable):
@@ -116,11 +121,12 @@ class RackImportTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     u_height = tables.Column(verbose_name='Height (U)')
 
     class Meta(BaseTable.Meta):
         model = Rack
-        fields = ('site', 'group', 'name', 'facility_id', 'u_height')
+        fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
 
 
 #
@@ -132,11 +138,12 @@ class ManufacturerTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     devicetype_count = tables.Column(verbose_name='Device Types')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=MANUFACTURER_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
+                                    verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = Manufacturer
-        fields = ('pk', 'name', 'devicetype_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions')
 
 
 #
@@ -228,11 +235,12 @@ class DeviceRoleTable(BaseTable):
     device_count = tables.Column(verbose_name='Devices')
     slug = tables.Column(verbose_name='Slug')
     color = tables.Column(verbose_name='Color')
-    edit = tables.TemplateColumn(template_code=DEVICEROLE_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
+                                    verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = DeviceRole
-        fields = ('pk', 'name', 'device_count', 'slug', 'color')
+        fields = ('pk', 'name', 'device_count', 'slug', 'color', 'actions')
 
 
 #
@@ -244,11 +252,11 @@ class PlatformTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     device_count = tables.Column(verbose_name='Devices')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=PLATFORM_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = Platform
-        fields = ('pk', 'name', 'device_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'device_count', 'slug', 'actions')
 
 
 #
@@ -259,6 +267,7 @@ class DeviceTable(BaseTable):
     pk = ToggleColumn()
     status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     device_role = tables.Column(verbose_name='Role')
@@ -268,11 +277,12 @@ class DeviceTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Device
-        fields = ('pk', 'name', 'status', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
+        fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
 
 
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     position = tables.Column(verbose_name='Position')
@@ -281,7 +291,7 @@ class DeviceImportTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Device
-        fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type')
+        fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
         empty_text = False
 
 

+ 6 - 0
netbox/dcim/tests/test_apis.py

@@ -15,6 +15,7 @@ class SiteTest(APITestCase):
         'id',
         'name',
         'slug',
+        'tenant',
         'facility',
         'asn',
         'physical_address',
@@ -40,6 +41,7 @@ class SiteTest(APITestCase):
         'display_name',
         'site',
         'group',
+        'tenant',
         'u_height',
         'comments'
     ]
@@ -115,6 +117,7 @@ class RackTest(APITestCase):
         'display_name',
         'site',
         'group',
+        'tenant',
         'u_height',
         'comments'
     ]
@@ -126,6 +129,7 @@ class RackTest(APITestCase):
         'display_name',
         'site',
         'group',
+        'tenant',
         'u_height',
         'comments',
         'front_units',
@@ -311,6 +315,7 @@ class DeviceTest(APITestCase):
         'display_name',
         'device_type',
         'device_role',
+        'tenant',
         'platform',
         'serial',
         'rack',
@@ -388,6 +393,7 @@ class DeviceTest(APITestCase):
             'rack_name',
             'serial',
             'status',
+            'tenant',
         ]
 
         response = self.client.get(endpoint)

+ 1 - 0
netbox/dcim/urls.py

@@ -15,6 +15,7 @@ urlpatterns = [
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
     url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
+    url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),

+ 42 - 10
netbox/dcim/views.py

@@ -15,7 +15,7 @@ from django.views.generic import View
 
 from ipam.models import Prefix, IPAddress, VLAN
 from circuits.models import Circuit
-from extras.models import TopologyMap
+from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from utilities.forms import ConfirmationForm
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -61,9 +61,11 @@ def expand_pattern(string):
 #
 
 class SiteListView(ObjectListView):
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     filter = filters.SiteFilter
+    filter_form = forms.SiteFilterForm
     table = tables.SiteTable
+    edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
     template_name = 'dcim/site_list.html'
 
 
@@ -79,12 +81,14 @@ def site(request, slug):
     }
     rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
     topology_maps = TopologyMap.objects.filter(site=site)
+    show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists()
 
     return render(request, 'dcim/site.html', {
         'site': site,
         'stats': stats,
         'rack_groups': rack_groups,
         'topology_maps': topology_maps,
+        'show_graphs': show_graphs,
     })
 
 
@@ -110,6 +114,24 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
     obj_list_url = 'dcim:site_list'
 
 
+class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_site'
+    cls = Site
+    form = forms.SiteBulkEditForm
+    template_name = 'dcim/site_bulk_edit.html'
+    default_redirect_url = 'dcim:site_list'
+
+    def update_objects(self, pk_list, form):
+
+        fields_to_update = {}
+        if form.cleaned_data['tenant'] == 0:
+            fields_to_update['tenant'] = None
+        elif form.cleaned_data['tenant']:
+            fields_to_update['tenant'] = form.cleaned_data['tenant']
+
+        return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
+
+
 #
 # Rack groups
 #
@@ -141,7 +163,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class RackListView(ObjectListView):
-    queryset = Rack.objects.select_related('site').prefetch_related('devices__device_type').annotate(device_count=Count('devices', distinct=True), u_consumed=Sum('devices__device_type__u_height'))
+    queryset = Rack.objects.select_related('site').prefetch_related('devices__device_type')\
+        .annotate(device_count=Count('devices', distinct=True), u_consumed=Sum('devices__device_type__u_height'))
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     table = tables.RackTable
@@ -153,7 +176,7 @@ def rack(request, pk):
 
     rack = get_object_or_404(Rack, pk=pk)
 
-    nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)\
+    nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\
         .select_related('device_type__manufacturer')
     next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
     prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
@@ -200,7 +223,11 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
-        for field in ['site', 'group', 'u_height', 'comments']:
+        if form.cleaned_data['tenant'] == 0:
+            fields_to_update['tenant'] = None
+        elif form.cleaned_data['tenant']:
+            fields_to_update['tenant'] = form.cleaned_data['tenant']
+        for field in ['site', 'group', 'tenant', 'u_height', 'comments']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
 
@@ -560,6 +587,9 @@ def device(request, pk):
             related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk)\
                 .select_related('rack', 'device_type__manufacturer')[:10]
 
+    # Show graph button on interfaces only if at least one graph has been created.
+    show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
+
     return render(request, 'dcim/device.html', {
         'device': device,
         'console_ports': console_ports,
@@ -572,6 +602,7 @@ def device(request, pk):
         'ip_addresses': ip_addresses,
         'secrets': secrets,
         'related_devices': related_devices,
+        'show_graphs': show_graphs,
     })
 
 
@@ -625,14 +656,15 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
-        if form.cleaned_data['platform']:
-            fields_to_update['platform'] = form.cleaned_data['platform']
-        elif form.cleaned_data['platform_delete']:
-            fields_to_update['platform'] = None
+        for field in ['tenant', 'platform']:
+            if form.cleaned_data[field] == 0:
+                fields_to_update[field] = None
+            elif form.cleaned_data[field]:
+                fields_to_update[field] = form.cleaned_data[field]
         if form.cleaned_data['status']:
             status = form.cleaned_data['status']
             fields_to_update['status'] = True if status == 'True' else False
-        for field in ['device_type', 'device_role', 'serial']:
+        for field in ['tenant', 'device_type', 'device_role', 'serial']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
 

+ 11 - 6
netbox/ipam/admin.py

@@ -7,7 +7,12 @@ from .models import (
 
 @admin.register(VRF)
 class VRFAdmin(admin.ModelAdmin):
-    list_display = ['name', 'rd']
+    list_display = ['name', 'rd', 'tenant', 'enforce_unique']
+    list_filter = ['tenant']
+
+    def get_queryset(self, request):
+        qs = super(VRFAdmin, self).get_queryset(request)
+        return qs.select_related('tenant')
 
 
 @admin.register(Role)
@@ -35,7 +40,7 @@ class AggregateAdmin(admin.ModelAdmin):
 
 @admin.register(Prefix)
 class PrefixAdmin(admin.ModelAdmin):
-    list_display = ['prefix', 'vrf', 'site', 'status', 'role', 'vlan']
+    list_display = ['prefix', 'vrf', 'tenant', 'site', 'status', 'role', 'vlan']
     list_filter = ['family', 'site', 'status', 'role']
     search_fields = ['prefix']
 
@@ -46,7 +51,7 @@ class PrefixAdmin(admin.ModelAdmin):
 
 @admin.register(IPAddress)
 class IPAddressAdmin(admin.ModelAdmin):
-    list_display = ['address', 'vrf', 'nat_inside']
+    list_display = ['address', 'vrf', 'tenant', 'nat_inside']
     list_filter = ['family']
     fields = ['address', 'vrf', 'device', 'interface', 'nat_inside']
     readonly_fields = ['interface', 'device', 'nat_inside']
@@ -67,10 +72,10 @@ class VLANGroupAdmin(admin.ModelAdmin):
 
 @admin.register(VLAN)
 class VLANAdmin(admin.ModelAdmin):
-    list_display = ['site', 'vid', 'name', 'status', 'role']
-    list_filter = ['site', 'status', 'role']
+    list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role']
+    list_filter = ['site', 'tenant', 'status', 'role']
     search_fields = ['vid', 'name']
 
     def get_queryset(self, request):
         qs = super(VLANAdmin, self).get_queryset(request)
-        return qs.select_related('site', 'role')
+        return qs.select_related('site', 'tenant', 'role')

+ 20 - 6
netbox/ipam/api/serializers.py

@@ -2,6 +2,7 @@ from rest_framework import serializers
 
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
+from tenancy.api.serializers import TenantNestedSerializer
 
 
 #
@@ -9,10 +10,11 @@ from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLAN
 #
 
 class VRFSerializer(serializers.ModelSerializer):
+    tenant = TenantNestedSerializer()
 
     class Meta:
         model = VRF
-        fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
+        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
 class VRFNestedSerializer(VRFSerializer):
@@ -21,6 +23,15 @@ class VRFNestedSerializer(VRFSerializer):
         fields = ['id', 'name', 'rd']
 
 
+class VRFTenantSerializer(VRFSerializer):
+    """
+    Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses.
+    """
+
+    class Meta(VRFSerializer.Meta):
+        fields = ['id', 'name', 'rd', 'tenant']
+
+
 #
 # Roles
 #
@@ -98,11 +109,12 @@ class VLANGroupNestedSerializer(VLANGroupSerializer):
 class VLANSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     group = VLANGroupNestedSerializer()
+    tenant = TenantNestedSerializer()
     role = RoleNestedSerializer()
 
     class Meta:
         model = VLAN
-        fields = ['id', 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'display_name']
+        fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name']
 
 
 class VLANNestedSerializer(VLANSerializer):
@@ -117,13 +129,14 @@ class VLANNestedSerializer(VLANSerializer):
 
 class PrefixSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
-    vrf = VRFNestedSerializer()
+    vrf = VRFTenantSerializer()
+    tenant = TenantNestedSerializer()
     vlan = VLANNestedSerializer()
     role = RoleNestedSerializer()
 
     class Meta:
         model = Prefix
-        fields = ['id', 'family', 'prefix', 'site', 'vrf', 'vlan', 'status', 'role', 'description']
+        fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description']
 
 
 class PrefixNestedSerializer(PrefixSerializer):
@@ -137,12 +150,13 @@ class PrefixNestedSerializer(PrefixSerializer):
 #
 
 class IPAddressSerializer(serializers.ModelSerializer):
-    vrf = VRFNestedSerializer()
+    vrf = VRFTenantSerializer()
+    tenant = TenantNestedSerializer()
     interface = InterfaceNestedSerializer()
 
     class Meta:
         model = IPAddress
-        fields = ['id', 'family', 'address', 'vrf', 'interface', 'description', 'nat_inside', 'nat_outside']
+        fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside']
 
 
 class IPAddressNestedSerializer(IPAddressSerializer):

+ 10 - 10
netbox/ipam/api/views.py

@@ -14,7 +14,7 @@ class VRFListView(generics.ListAPIView):
     """
     List all VRFs
     """
-    queryset = VRF.objects.all()
+    queryset = VRF.objects.select_related('tenant')
     serializer_class = serializers.VRFSerializer
     filter_class = filters.VRFFilter
 
@@ -23,7 +23,7 @@ class VRFDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single VRF
     """
-    queryset = VRF.objects.all()
+    queryset = VRF.objects.select_related('tenant')
     serializer_class = serializers.VRFSerializer
 
 
@@ -96,7 +96,7 @@ class PrefixListView(generics.ListAPIView):
     """
     List prefixes (filterable)
     """
-    queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     serializer_class = serializers.PrefixSerializer
     filter_class = filters.PrefixFilter
 
@@ -105,7 +105,7 @@ class PrefixDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single prefix
     """
-    queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     serializer_class = serializers.PrefixSerializer
 
 
@@ -117,7 +117,7 @@ class IPAddressListView(generics.ListAPIView):
     """
     List IP addresses (filterable)
     """
-    queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
         .prefetch_related('nat_outside')
     serializer_class = serializers.IPAddressSerializer
     filter_class = filters.IPAddressFilter
@@ -127,7 +127,7 @@ class IPAddressDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single IP address
     """
-    queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
         .prefetch_related('nat_outside')
     serializer_class = serializers.IPAddressSerializer
 
@@ -140,7 +140,7 @@ class VLANGroupListView(generics.ListAPIView):
     """
     List all VLAN groups
     """
-    queryset = VLANGroup.objects.all()
+    queryset = VLANGroup.objects.select_related('site')
     serializer_class = serializers.VLANGroupSerializer
     filter_class = filters.VLANGroupFilter
 
@@ -149,7 +149,7 @@ class VLANGroupDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single VLAN group
     """
-    queryset = VLANGroup.objects.all()
+    queryset = VLANGroup.objects.select_related('site')
     serializer_class = serializers.VLANGroupSerializer
 
 
@@ -161,7 +161,7 @@ class VLANListView(generics.ListAPIView):
     """
     List VLANs (filterable)
     """
-    queryset = VLAN.objects.select_related('site', 'role')
+    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
     serializer_class = serializers.VLANSerializer
     filter_class = filters.VLANFilter
 
@@ -170,5 +170,5 @@ class VLANDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single VLAN
     """
-    queryset = VLAN.objects.select_related('site', 'role')
+    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
     serializer_class = serializers.VLANSerializer

+ 123 - 8
netbox/ipam/filters.py

@@ -2,17 +2,42 @@ import django_filters
 from netaddr import IPNetwork
 from netaddr.core import AddrFormatError
 
+from django.db.models import Q
+
 from dcim.models import Site, Device, Interface
+from tenancy.models import Tenant
 
 from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
 
 
 class VRFFilter(django_filters.FilterSet):
+    q = django_filters.MethodFilter(
+        action='search',
+        label='Search',
+    )
     name = django_filters.CharFilter(
         name='name',
         lookup_type='icontains',
         label='Name',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
+
+    def search(self, queryset, value):
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(rd__icontains=value) |
+            Q(description__icontains=value)
+        )
 
     class Meta:
         model = VRF
@@ -20,6 +45,10 @@ class VRFFilter(django_filters.FilterSet):
 
 
 class AggregateFilter(django_filters.FilterSet):
+    q = django_filters.MethodFilter(
+        action='search',
+        label='Search',
+    )
     rir_id = django_filters.ModelMultipleChoiceFilter(
         name='rir',
         queryset=RIR.objects.all(),
@@ -36,6 +65,15 @@ class AggregateFilter(django_filters.FilterSet):
         model = Aggregate
         fields = ['family', 'rir_id', 'rir', 'date_added']
 
+    def search(self, queryset, value):
+        qs_filter = Q(description__icontains=value)
+        try:
+            prefix = str(IPNetwork(value.strip()).cidr)
+            qs_filter |= Q(prefix__net_contains_or_equals=prefix)
+        except AddrFormatError:
+            pass
+        return queryset.filter(qs_filter)
+
 
 class PrefixFilter(django_filters.FilterSet):
     q = django_filters.MethodFilter(
@@ -55,6 +93,14 @@ class PrefixFilter(django_filters.FilterSet):
         action='_vrf',
         label='VRF',
     )
+    tenant_id = django_filters.MethodFilter(
+        action='_tenant_id',
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.MethodFilter(
+        action='_tenant',
+        label='Tenant',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
@@ -92,12 +138,13 @@ class PrefixFilter(django_filters.FilterSet):
         fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
 
     def search(self, queryset, value):
-        value = value.strip()
+        qs_filter = Q(description__icontains=value)
         try:
-            query = str(IPNetwork(value).cidr)
-            return queryset.filter(prefix__net_contains_or_equals=query)
+            prefix = str(IPNetwork(value.strip()).cidr)
+            qs_filter |= Q(prefix__net_contains_or_equals=prefix)
         except AddrFormatError:
-            return queryset.none()
+            pass
+        return queryset.filter(qs_filter)
 
     def search_by_parent(self, queryset, value):
         value = value.strip()
@@ -120,6 +167,24 @@ class PrefixFilter(django_filters.FilterSet):
             return queryset.filter(vrf__isnull=True)
         return queryset.filter(vrf__pk=value)
 
+    def _tenant(self, queryset, value):
+        if str(value) == '':
+            return queryset
+        return queryset.filter(
+            Q(tenant__slug=value) |
+            Q(tenant__isnull=True, vrf__tenant__slug=value)
+        )
+
+    def _tenant_id(self, queryset, value):
+        try:
+            value = int(value)
+        except ValueError:
+            return queryset.none()
+        return queryset.filter(
+            Q(tenant__pk=value) |
+            Q(tenant__isnull=True, vrf__tenant__pk=value)
+        )
+
 
 class IPAddressFilter(django_filters.FilterSet):
     q = django_filters.MethodFilter(
@@ -135,6 +200,14 @@ class IPAddressFilter(django_filters.FilterSet):
         action='_vrf',
         label='VRF',
     )
+    tenant_id = django_filters.MethodFilter(
+        action='_tenant_id',
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.MethodFilter(
+        action='_tenant',
+        label='Tenant',
+    )
     device_id = django_filters.ModelMultipleChoiceFilter(
         name='interface__device',
         queryset=Device.objects.all(),
@@ -157,12 +230,13 @@ class IPAddressFilter(django_filters.FilterSet):
         fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
 
     def search(self, queryset, value):
-        value = value.strip()
+        qs_filter = Q(description__icontains=value)
         try:
-            query = str(IPNetwork(value))
-            return queryset.filter(address__net_host=query)
+            ipaddress = str(IPNetwork(value.strip()))
+            qs_filter |= Q(address__net_host=ipaddress)
         except AddrFormatError:
-            return queryset.none()
+            pass
+        return queryset.filter(qs_filter)
 
     def _vrf(self, queryset, value):
         if str(value) == '':
@@ -175,6 +249,24 @@ class IPAddressFilter(django_filters.FilterSet):
             return queryset.filter(vrf__isnull=True)
         return queryset.filter(vrf__pk=value)
 
+    def _tenant(self, queryset, value):
+        if str(value) == '':
+            return queryset
+        return queryset.filter(
+            Q(tenant__slug=value) |
+            Q(tenant__isnull=True, vrf__tenant__slug=value)
+        )
+
+    def _tenant_id(self, queryset, value):
+        try:
+            value = int(value)
+        except ValueError:
+            return queryset.none()
+        return queryset.filter(
+            Q(tenant__pk=value) |
+            Q(tenant__isnull=True, vrf__tenant__pk=value)
+        )
+
 
 class VLANGroupFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
@@ -195,6 +287,10 @@ class VLANGroupFilter(django_filters.FilterSet):
 
 
 class VLANFilter(django_filters.FilterSet):
+    q = django_filters.MethodFilter(
+        action='search',
+        label='Search',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
@@ -226,6 +322,17 @@ class VLANFilter(django_filters.FilterSet):
         name='vid',
         label='VLAN number (1-4095)',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
     role_id = django_filters.ModelMultipleChoiceFilter(
         name='role',
         queryset=Role.objects.all(),
@@ -241,3 +348,11 @@ class VLANFilter(django_filters.FilterSet):
     class Meta:
         model = VLAN
         fields = ['site_id', 'site', 'vid', 'name', 'status', 'role_id', 'role']
+
+    def search(self, queryset, value):
+        qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
+        try:
+            qs_filter |= Q(vid=int(value))
+        except ValueError:
+            pass
+        return queryset.filter(qs_filter)

+ 125 - 0
netbox/ipam/fixtures/initial_data.json

@@ -0,0 +1,125 @@
+[
+{
+    "model": "ipam.aggregate",
+    "pk": 1,
+    "fields": {
+        "created": "2016-08-01",
+        "last_updated": "2016-08-01T15:22:20.938Z",
+        "family": 4,
+        "prefix": "10.0.0.0/8",
+        "rir": 6,
+        "date_added": null,
+        "description": "Private IPv4 space"
+    }
+},
+{
+    "model": "ipam.aggregate",
+    "pk": 2,
+    "fields": {
+        "created": "2016-08-01",
+        "last_updated": "2016-08-01T15:22:32.679Z",
+        "family": 4,
+        "prefix": "172.16.0.0/12",
+        "rir": 6,
+        "date_added": null,
+        "description": "Private IPv4 space"
+    }
+},
+{
+    "model": "ipam.aggregate",
+    "pk": 3,
+    "fields": {
+        "created": "2016-08-01",
+        "last_updated": "2016-08-01T15:22:42.289Z",
+        "family": 4,
+        "prefix": "192.168.0.0/16",
+        "rir": 6,
+        "date_added": null,
+        "description": "Private IPv4 space"
+    }
+},
+{
+    "model": "ipam.rir",
+    "pk": 1,
+    "fields": {
+        "name": "ARIN",
+        "slug": "arin"
+    }
+},
+{
+    "model": "ipam.rir",
+    "pk": 2,
+    "fields": {
+        "name": "RIPE",
+        "slug": "ripe"
+    }
+},
+{
+    "model": "ipam.rir",
+    "pk": 3,
+    "fields": {
+        "name": "APNIC",
+        "slug": "apnic"
+    }
+},
+{
+    "model": "ipam.rir",
+    "pk": 4,
+    "fields": {
+        "name": "LACNIC",
+        "slug": "lacnic"
+    }
+},
+{
+    "model": "ipam.rir",
+    "pk": 5,
+    "fields": {
+        "name": "AFRINIC",
+        "slug": "afrinic"
+    }
+},
+{
+    "model": "ipam.rir",
+    "pk": 6,
+    "fields": {
+        "name": "RFC 1918",
+        "slug": "rfc-1918"
+    }
+},
+{
+    "model": "ipam.role",
+    "pk": 1,
+    "fields": {
+        "name": "Production",
+        "slug": "production",
+        "weight": 1000
+    }
+},
+{
+    "model": "ipam.role",
+    "pk": 2,
+    "fields": {
+        "name": "Development",
+        "slug": "development",
+        "weight": 1000
+    }
+},
+{
+    "model": "ipam.role",
+    "pk": 3,
+    "fields": {
+        "name": "Management",
+        "slug": "management",
+        "weight": 1000
+    }
+},
+{
+    "model": "ipam.role",
+    "pk": 4,
+    "fields": {
+        "name": "Backup",
+        "slug": "backup",
+        "weight": 1000
+    }
+}
+]

+ 70 - 25
netbox/ipam/forms.py

@@ -4,6 +4,8 @@ from django import forms
 from django.db.models import Count
 
 from dcim.models import Site, Device, Interface
+from tenancy.forms import bulkedit_tenant_choices
+from tenancy.models import Tenant
 from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
 
 from .models import (
@@ -15,6 +17,18 @@ FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
 FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
 
 
+def bulkedit_vrf_choices():
+    """
+    Include an option to assign the object to the global table.
+    """
+    choices = [
+        (None, '---------'),
+        (0, 'Global'),
+    ]
+    choices += [(v.pk, v.name) for v in VRF.objects.all()]
+    return choices
+
+
 #
 # VRFs
 #
@@ -23,7 +37,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = VRF
-        fields = ['name', 'rd', 'enforce_unique', 'description']
+        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
         labels = {
             'rd': "RD",
         }
@@ -33,10 +47,12 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
 
 
 class VRFFromCSVForm(forms.ModelForm):
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
 
     class Meta:
         model = VRF
-        fields = ['name', 'rd', 'enforce_unique', 'description']
+        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
 class VRFImportForm(BulkImportForm, BootstrapMixin):
@@ -45,9 +61,20 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
 
 class VRFBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     description = forms.CharField(max_length=100, required=False)
 
 
+def vrf_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
+
+
+class VRFFilterForm(forms.Form, BootstrapMixin):
+    tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
+
+
 #
 # RIRs
 #
@@ -131,7 +158,7 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Prefix
-        fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'description']
+        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description']
         help_texts = {
             'prefix': "IPv4 or IPv6 network",
             'vrf': "VRF (if applicable)",
@@ -172,6 +199,8 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
 class PrefixFromCSVForm(forms.ModelForm):
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
                                  error_messages={'invalid_choice': 'VRF not found.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
     vlan_group_name = forms.CharField(required=False)
@@ -182,7 +211,8 @@ class PrefixFromCSVForm(forms.ModelForm):
 
     class Meta:
         model = Prefix
-        fields = ['prefix', 'vrf', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'description']
+        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role',
+                  'description']
 
     def clean(self):
 
@@ -228,18 +258,16 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin):
 class PrefixBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
-    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
-                                 help_text="Select the VRF to assign, or check below to remove VRF assignment")
-    vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
+    vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
 def prefix_vrf_choices():
-    vrf_choices = [('', 'All'), (0, 'Global')]
-    vrf_choices += [(v.pk, v.name) for v in VRF.objects.all()]
-    return vrf_choices
+    vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes'))
+    return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices]
 
 
 def prefix_site_choices():
@@ -261,12 +289,16 @@ def prefix_role_choices():
 
 class PrefixFilterForm(forms.Form, BootstrapMixin):
     parent = forms.CharField(required=False, label='Search Within')
-    vrf = forms.ChoiceField(required=False, choices=prefix_vrf_choices, label='VRF')
-    status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices)
+    vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
+                                    widget=forms.SelectMultiple(attrs={'size': 6}))
+    tenant = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False,
+                                            widget=forms.SelectMultiple(attrs={'size': 6}))
+    status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 6}))
     site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+                                     widget=forms.SelectMultiple(attrs={'size': 6}))
     role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+                                     widget=forms.SelectMultiple(attrs={'size': 6}))
     expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
 
 
@@ -289,7 +321,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = IPAddress
-        fields = ['address', 'vrf', 'nat_device', 'nat_inside', 'description']
+        fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description']
         help_texts = {
             'address': "IPv4 or IPv6 address and mask",
             'vrf': "VRF (if applicable)",
@@ -338,6 +370,8 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
 class IPAddressFromCSVForm(forms.ModelForm):
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
                                  error_messages={'invalid_choice': 'VRF not found.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
                                     error_messages={'invalid_choice': 'Device not found.'})
     interface_name = forms.CharField(required=False)
@@ -345,7 +379,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
 
     class Meta:
         model = IPAddress
-        fields = ['address', 'vrf', 'device', 'interface_name', 'is_primary', 'description']
+        fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description']
 
     def clean(self):
 
@@ -390,9 +424,8 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin):
 
 class IPAddressBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
-    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
-                                 help_text="Select the VRF to assign, or check below to remove VRF assignment")
-    vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
+    vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     description = forms.CharField(max_length=100, required=False)
 
 
@@ -401,14 +434,16 @@ def ipaddress_family_choices():
 
 
 def ipaddress_vrf_choices():
-    vrf_choices = [('', 'All'), (0, 'Global')]
-    vrf_choices += [(v.pk, v.name) for v in VRF.objects.all()]
-    return vrf_choices
+    vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
+    return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices]
 
 
 class IPAddressFilterForm(forms.Form, BootstrapMixin):
     family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family')
-    vrf = forms.ChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF')
+    vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
+                                    widget=forms.SelectMultiple(attrs={'size': 6}))
+    tenant = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False,
+                                            widget=forms.SelectMultiple(attrs={'size': 6}))
 
 
 #
@@ -444,7 +479,7 @@ class VLANForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = VLAN
-        fields = ['site', 'group', 'vid', 'name', 'description', 'status', 'role']
+        fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
         help_texts = {
             'site': "The site at which this VLAN exists",
             'group': "VLAN group (optional)",
@@ -475,13 +510,15 @@ class VLANFromCSVForm(forms.ModelForm):
                                   error_messages={'invalid_choice': 'Device not found.'})
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
                                    error_messages={'invalid_choice': 'VLAN group not found.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Invalid role.'})
 
     class Meta:
         model = VLAN
-        fields = ['site', 'group', 'vid', 'name', 'status_name', 'role', 'description']
+        fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
 
     def save(self, *args, **kwargs):
         m = super(VLANFromCSVForm, self).save(commit=False)
@@ -500,6 +537,7 @@ class VLANBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
@@ -515,6 +553,11 @@ def vlan_group_choices():
     return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
 
 
+def vlan_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
+
+
 def vlan_status_choices():
     status_counts = {}
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -532,6 +575,8 @@ class VLANFilterForm(forms.Form, BootstrapMixin):
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
     group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
     status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
     role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))

+ 27 - 0
netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-27 14:39
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_initial'),
+        ('ipam', '0005_auto_20160725_1842'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vlan',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='vrf',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vrfs', to='tenancy.Tenant'),
+        ),
+    ]

+ 27 - 0
netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-28 15:32
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_initial'),
+        ('ipam', '0006_vrf_vlan_add_tenant'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ipaddress',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='prefix',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='tenancy.Tenant'),
+        ),
+    ]

+ 10 - 2
netbox/ipam/models.py

@@ -7,6 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 
 from dcim.models import Interface
+from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 
 from .fields import IPNetworkField, IPAddressField
@@ -46,6 +47,7 @@ class VRF(CreatedUpdatedModel):
     """
     name = models.CharField(max_length=50)
     rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
+    tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT)
     enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
                                          help_text="Prevent duplicate prefixes/IP addresses within this VRF")
     description = models.CharField(max_length=100, blank=True)
@@ -65,6 +67,8 @@ class VRF(CreatedUpdatedModel):
         return ','.join([
             self.name,
             self.rd,
+            self.tenant.name if self.tenant else '',
+            'True' if self.enforce_unique else '',
             self.description,
         ])
 
@@ -229,6 +233,7 @@ class Prefix(CreatedUpdatedModel):
     site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
     vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
                             verbose_name='VRF')
+    tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
     vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
                              verbose_name='VLAN')
     status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1)
@@ -291,7 +296,7 @@ class Prefix(CreatedUpdatedModel):
 
 class IPAddress(CreatedUpdatedModel):
     """
-    An IPAddress represents an individual IPV4 or IPv6 address and its mask. The mask length should match what is
+    An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
     configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
     Prefixes, IPAddresses can optionally be assigned to a VRF. An IPAddress can optionally be assigned to an Interface.
     Interfaces can have zero or more IPAddresses assigned to them.
@@ -304,6 +309,7 @@ class IPAddress(CreatedUpdatedModel):
     address = IPAddressField()
     vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
                             verbose_name='VRF')
+    tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
     interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
                                   null=True)
     nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
@@ -407,9 +413,10 @@ class VLAN(CreatedUpdatedModel):
         MaxValueValidator(4094)
     ])
     name = models.CharField(max_length=64)
-    description = models.CharField(max_length=100, blank=True)
+    tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
     status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
     role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
+    description = models.CharField(max_length=100, blank=True)
 
     class Meta:
         ordering = ['site', 'group', 'vid']
@@ -438,6 +445,7 @@ class VLAN(CreatedUpdatedModel):
             self.group.name if self.group else '',
             str(self.vid),
             self.name,
+            self.tenant.name if self.tenant else '',
             self.get_status_display(),
             self.role.name if self.role else '',
             self.description,

+ 38 - 18
netbox/ipam/tables.py

@@ -6,8 +6,10 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
-RIR_EDIT_LINK = """
-{% if perms.ipam.change_rir %}<a href="{% url 'ipam:rir_edit' slug=record.slug %}">Edit</a>{% endif %}
+RIR_ACTIONS = """
+{% if perms.ipam.change_rir %}
+    <a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
 """
 
 UTILIZATION_GRAPH = """
@@ -15,8 +17,10 @@ UTILIZATION_GRAPH = """
 {% utilization_graph record.get_utilization %}
 """
 
-ROLE_EDIT_LINK = """
-{% if perms.ipam.change_role %}<a href="{% url 'ipam:role_edit' slug=record.slug %}">Edit</a>{% endif %}
+ROLE_ACTIONS = """
+{% if perms.ipam.change_role %}
+    <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
 """
 
 PREFIX_LINK = """
@@ -43,9 +47,19 @@ STATUS_LABEL = """
 {% endif %}
 """
 
-VLANGROUP_EDIT_LINK = """
+VLANGROUP_ACTIONS = """
 {% if perms.ipam.change_vlangroup %}
-    <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}">Edit</a>
+    <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+TENANT_LINK = """
+{% if record.tenant %}
+    <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}">{{ record.tenant }}</a>
+{% elif record.vrf.tenant %}
+    <a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}">{{ record.vrf.tenant }}</a>*
+{% else %}
+    &mdash;
 {% endif %}
 """
 
@@ -58,11 +72,12 @@ class VRFTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
     rd = tables.Column(verbose_name='RD')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     description = tables.Column(orderable=False, verbose_name='Description')
 
     class Meta(BaseTable.Meta):
         model = VRF
-        fields = ('pk', 'name', 'rd', 'description')
+        fields = ('pk', 'name', 'rd', 'tenant', 'description')
 
 
 #
@@ -74,11 +89,11 @@ class RIRTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     aggregate_count = tables.Column(verbose_name='Aggregates')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=RIR_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = RIR
-        fields = ('pk', 'name', 'aggregate_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'aggregate_count', 'slug', 'actions')
 
 
 #
@@ -109,11 +124,11 @@ class RoleTable(BaseTable):
     prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
     vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=ROLE_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = Role
-        fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'actions')
 
 
 #
@@ -124,14 +139,15 @@ class PrefixTable(BaseTable):
     pk = ToggleColumn()
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
-    vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
+    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     role = tables.Column(verbose_name='Role')
     description = tables.Column(orderable=False, verbose_name='Description')
 
     class Meta(BaseTable.Meta):
         model = Prefix
-        fields = ('pk', 'prefix', 'status', 'vrf', 'site', 'role', 'description')
+        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description')
 
 
 class PrefixBriefTable(BaseTable):
@@ -143,6 +159,7 @@ class PrefixBriefTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Prefix
         fields = ('prefix', 'status', 'site', 'role')
+        orderable = False
 
 
 #
@@ -152,7 +169,8 @@ class PrefixBriefTable(BaseTable):
 class IPAddressTable(BaseTable):
     pk = ToggleColumn()
     address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
-    vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
+    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
                                verbose_name='Device')
     interface = tables.Column(orderable=False, verbose_name='Interface')
@@ -160,7 +178,7 @@ class IPAddressTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = IPAddress
-        fields = ('pk', 'address', 'vrf', 'device', 'interface', 'description')
+        fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description')
 
 
 class IPAddressBriefTable(BaseTable):
@@ -186,11 +204,12 @@ class VLANGroupTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     vlan_count = tables.Column(verbose_name='VLANs')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=VLANGROUP_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
+                                    verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = VLANGroup
-        fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'actions')
 
 
 #
@@ -203,9 +222,10 @@ class VLANTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     name = tables.Column(verbose_name='Name')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     role = tables.Column(verbose_name='Role')
 
     class Meta(BaseTable.Meta):
         model = VLAN
-        fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role')
+        fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role')

+ 30 - 16
netbox/ipam/views.py

@@ -36,8 +36,9 @@ def add_available_prefixes(parent, prefix_list):
 #
 
 class VRFListView(ObjectListView):
-    queryset = VRF.objects.all()
+    queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
+    filter_form = forms.VRFFilterForm
     table = tables.VRFTable
     edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
     template_name = 'ipam/vrf_list.html'
@@ -47,10 +48,11 @@ def vrf(request, pk):
 
     vrf = get_object_or_404(VRF.objects.all(), pk=pk)
     prefixes = Prefix.objects.filter(vrf=vrf)
+    prefix_table = tables.PrefixBriefTable(prefixes)
 
     return render(request, 'ipam/vrf.html', {
         'vrf': vrf,
-        'prefixes': prefixes,
+        'prefix_table': prefix_table,
     })
 
 
@@ -85,6 +87,10 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
+        if form.cleaned_data['tenant'] == 0:
+            fields_to_update['tenant'] = None
+        elif form.cleaned_data['tenant']:
+            fields_to_update['tenant'] = form.cleaned_data['tenant']
         for field in ['description']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
@@ -145,7 +151,7 @@ class AggregateListView(ObjectListView):
             if a.prefix.version == 4:
                 ipv4_total += a.prefix.size
             elif a.prefix.version == 6:
-                ipv6_total += a.prefix.size / 2**64
+                ipv6_total += a.prefix.size / 2 ** 64
 
         return {
             'ipv4_total': ipv4_total,
@@ -248,7 +254,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class PrefixListView(ObjectListView):
-    queryset = Prefix.objects.select_related('site', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'role')
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     table = tables.PrefixTable
@@ -271,7 +277,8 @@ def prefix(request, pk):
         aggregate = None
 
     # Count child IP addresses
-    ipaddress_count = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix)).count()
+    ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
+        .count()
 
     # Parent prefixes table
     parent_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contains=str(prefix.prefix))\
@@ -336,10 +343,11 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
-        if form.cleaned_data['vrf']:
-            fields_to_update['vrf'] = form.cleaned_data['vrf']
-        elif form.cleaned_data['vrf_global']:
-            fields_to_update['vrf'] = None
+        for field in ['vrf', 'tenant']:
+            if form.cleaned_data[field] == 0:
+                fields_to_update[field] = None
+            elif form.cleaned_data[field]:
+                fields_to_update[field] = form.cleaned_data[field]
         for field in ['site', 'status', 'role', 'description']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
@@ -358,7 +366,7 @@ def prefix_ipaddresses(request, pk):
     prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
 
     # Find all IPAddresses belonging to this Prefix
-    ipaddresses = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix))\
+    ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
         .select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
 
     ip_table = tables.IPAddressTable(ipaddresses)
@@ -378,7 +386,7 @@ def prefix_ipaddresses(request, pk):
 #
 
 class IPAddressListView(ObjectListView):
-    queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'interface__device')
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     table = tables.IPAddressTable
@@ -460,10 +468,11 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
-        if form.cleaned_data['vrf']:
-            fields_to_update['vrf'] = form.cleaned_data['vrf']
-        elif form.cleaned_data['vrf_global']:
-            fields_to_update['vrf'] = None
+        for field in ['vrf', 'tenant']:
+            if form.cleaned_data[field] == 0:
+                fields_to_update[field] = None
+            elif form.cleaned_data[field]:
+                fields_to_update[field] = form.cleaned_data[field]
         for field in ['description']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
@@ -520,10 +529,11 @@ def vlan(request, pk):
 
     vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
     prefixes = Prefix.objects.filter(vlan=vlan)
+    prefix_table = tables.PrefixBriefTable(prefixes)
 
     return render(request, 'ipam/vlan.html', {
         'vlan': vlan,
-        'prefixes': prefixes,
+        'prefix_table': prefix_table,
     })
 
 
@@ -558,6 +568,10 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
+        if form.cleaned_data['tenant'] == 0:
+            fields_to_update['tenant'] = None
+        elif form.cleaned_data['tenant']:
+            fields_to_update['tenant'] = form.cleaned_data['tenant']
         for field in ['site', 'group', 'status', 'role', 'description']:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]

+ 2 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
 
 
-VERSION = '1.3.2'
+VERSION = '1.4.0'
 
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -108,6 +108,7 @@ INSTALLED_APPS = (
     'ipam',
     'extras',
     'secrets',
+    'tenancy',
     'users',
     'utilities',
 )

+ 2 - 0
netbox/netbox/urls.py

@@ -22,6 +22,7 @@ urlpatterns = [
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
     url(r'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
+    url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
     url(r'^profile/', include('users.urls', namespace='users')),
 
     # API
@@ -29,6 +30,7 @@ urlpatterns = [
     url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
     url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
     url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
+    url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
     url(r'^api/docs/', include('rest_framework_swagger.urls')),
     url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
 

+ 7 - 2
netbox/netbox/views.py

@@ -5,16 +5,20 @@ from django.shortcuts import render
 from circuits.models import Provider, Circuit
 from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
 from extras.models import UserAction
-from ipam.models import Aggregate, Prefix, IPAddress, VLAN
+from ipam.models import Aggregate, Prefix, IPAddress, VLAN, VRF
 from secrets.models import Secret
+from tenancy.models import Tenant
 
 
 def home(request):
 
     stats = {
 
-        # DCIM
+        # Organization
         'site_count': Site.objects.count(),
+        'tenant_count': Tenant.objects.count(),
+
+        # DCIM
         'rack_count': Rack.objects.count(),
         'device_count': Device.objects.count(),
         'interface_connections_count': InterfaceConnection.objects.count(),
@@ -22,6 +26,7 @@ def home(request):
         'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
 
         # IPAM
+        'vrf_count': VRF.objects.count(),
         'aggregate_count': Aggregate.objects.count(),
         'prefix_count': Prefix.objects.count(),
         'ipaddress_count': IPAddress.objects.count(),

+ 12 - 0
netbox/secrets/filters.py

@@ -1,10 +1,16 @@
 import django_filters
 
+from django.db.models import Q
+
 from .models import Secret, SecretRole
 from dcim.models import Device
 
 
 class SecretFilter(django_filters.FilterSet):
+    q = django_filters.MethodFilter(
+        action='search',
+        label='Search',
+    )
     role_id = django_filters.ModelMultipleChoiceFilter(
         name='role',
         queryset=SecretRole.objects.all(),
@@ -26,3 +32,9 @@ class SecretFilter(django_filters.FilterSet):
     class Meta:
         model = Secret
         fields = ['name', 'role_id', 'role', 'device']
+
+    def search(self, queryset, value):
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(device__name__icontains=value)
+        )

+ 42 - 0
netbox/secrets/fixtures/initial_data.json

@@ -0,0 +1,42 @@
+[
+{
+    "model": "secrets.secretrole",
+    "pk": 1,
+    "fields": {
+        "name": "Login Credentials",
+        "slug": "login-credentials",
+        "users": [],
+        "groups": []
+    }
+},
+{
+    "model": "secrets.secretrole",
+    "pk": 2,
+    "fields": {
+        "name": "RADIUS Key",
+        "slug": "radius-key",
+        "users": [],
+        "groups": []
+    }
+},
+{
+    "model": "secrets.secretrole",
+    "pk": 3,
+    "fields": {
+        "name": "SNMPv2 Community",
+        "slug": "snmpv2-community",
+        "users": [],
+        "groups": []
+    }
+},
+{
+    "model": "secrets.secretrole",
+    "pk": 4,
+    "fields": {
+        "name": "SNMPv3 Credentials",
+        "slug": "snmpv3-credentials",
+        "users": [],
+        "groups": []
+    }
+}
+]

+ 5 - 4
netbox/secrets/tables.py

@@ -6,9 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import SecretRole, Secret
 
 
-SECRETROLE_EDIT_LINK = """
+SECRETROLE_ACTIONS = """
 {% if perms.secrets.change_secretrole %}
-    <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}">Edit</a>
+    <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 """
 
@@ -22,11 +22,12 @@ class SecretRoleTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     secret_count = tables.Column(verbose_name='Secrets')
     slug = tables.Column(verbose_name='Slug')
-    edit = tables.TemplateColumn(template_code=SECRETROLE_EDIT_LINK, verbose_name='')
+    actions = tables.TemplateColumn(template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
+                                    verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = SecretRole
-        fields = ('pk', 'name', 'secret_count', 'slug', 'edit')
+        fields = ('pk', 'name', 'secret_count', 'slug', 'actions')
 
 
 #

+ 1 - 1
netbox/templates/500.html

@@ -13,7 +13,7 @@
             <div class="panel panel-danger" style="margin-top: 200px">
                 <div class="panel-heading">
                     <strong>
-                        <i class="glyphicon glyphicon-warning-sign"></i>
+                        <i class="fa fa-warning"></i>
                         Server Error
                     </strong>
                 </div>

+ 79 - 70
netbox/templates/_base.html

@@ -24,173 +24,182 @@
             <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|startswith:'/dcim/sites/' %} active{% endif %}">
-                        {% if perms.dcim.add_site %}
-                            <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Sites <span class="caret"></span></a>
-                            <ul class="dropdown-menu">
-                                <li><a href="{% url 'dcim:site_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Sites</a></li>
-                                <li><a href="{% url 'dcim:site_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Site</a></li>
-                                <li><a href="{% url 'dcim:site_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Sites</a></li>
-                            </ul>
-                        {% else %}
-                            <a href="{% url 'dcim:site_list' %}">Sites</a>
-                        {% endif %}
+                    <li class="dropdown{% if request.path|startswith:'/dcim/sites/' or 'tenancy' in request.path %} 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><a href="{% url 'dcim:site_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Sites</a></li>
+                            {% if perms.dcim.add_site %}
+                                <li><a href="{% url 'dcim:site_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Site</a></li>
+                                <li><a href="{% url 'dcim:site_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Sites</a></li>
+                            {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'tenancy:tenant_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Tenants</a></li>
+                            {% if perms.tenancy.add_tenant %}
+                                <li><a href="{% url 'tenancy:tenant_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Tenant</a></li>
+                                <li><a href="{% url 'tenancy:tenant_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Tenants</a></li>
+                            {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'tenancy:tenantgroup_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Tenant Groups</a></li>
+                            {% if perms.tenancy.add_tenantgroup %}
+                                <li><a href="{% url 'tenancy:tenantgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Tenant Group</a></li>
+                            {% endif %}
+                        </ul>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/dcim/rack' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
                         <ul class="dropdown-menu">
-                            <li><a href="{% url 'dcim:rack_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Racks</a></li>
+                            <li><a href="{% url 'dcim:rack_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Racks</a></li>
                             {% if perms.dcim.add_rack %}
-                                <li><a href="{% url 'dcim:rack_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Rack</a></li>
-                                <li><a href="{% url 'dcim:rack_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Racks</a></li>
+                                <li><a href="{% url 'dcim:rack_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack</a></li>
+                                <li><a href="{% url 'dcim:rack_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Racks</a></li>
                             {% endif %}
                             <li class="divider"></li>
-                            <li><a href="{% url 'dcim:rackgroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Rack Groups</a></li>
+                            <li><a href="{% url 'dcim:rackgroup_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Groups</a></li>
                             {% if perms.dcim.add_rackgroup %}
-                                <li><a href="{% url 'dcim:rackgroup_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
+                                <li><a href="{% url 'dcim:rackgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
                             {% endif %}
                         </ul>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/dcim/device' or request.path|startswith:'/dcim/manufacturers/' or request.path|startswith:'/dcim/platforms/' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
                         <ul class="dropdown-menu">
-                            <li><a href="{% url 'dcim:device_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Devices</a></li>
+                            <li><a href="{% url 'dcim:device_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Devices</a></li>
                             {% if perms.dcim.add_device %}
-                                <li><a href="{% url 'dcim:device_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Device</a></li>
-                                <li><a href="{% url 'dcim:device_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Devices</a></li>
+                                <li><a href="{% url 'dcim:device_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Device</a></li>
+                                <li><a href="{% url 'dcim:device_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Devices</a></li>
                             {% endif %}
                             {% if perms.ipam.add_device or perms.ipam.add_devicetype %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'dcim:devicetype_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Device Types</a></li>
+                            <li><a href="{% url 'dcim:devicetype_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Device Types</a></li>
                             {% if perms.dcim.add_devicetype %}
-                                <li><a href="{% url 'dcim:devicetype_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Device Type</a></li>
+                                <li><a href="{% url 'dcim:devicetype_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Device Type</a></li>
                             {% endif %}
                             <li class="divider"></li>
-                            <li><a href="{% url 'dcim:devicerole_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Device Roles</a></li>
+                            <li><a href="{% url 'dcim:devicerole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Device Roles</a></li>
                             {% if perms.dcim.add_devicerole %}
-                                <li><a href="{% url 'dcim:devicerole_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Device Role</a></li>
+                                <li><a href="{% url 'dcim:devicerole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Device Role</a></li>
                             {% endif %}
                             {% if perms.dcim.add_devicerole or perms.dcim.add_manufacturer %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'dcim:manufacturer_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Manufacturers</a></li>
+                            <li><a href="{% url 'dcim:manufacturer_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Manufacturers</a></li>
                             {% if perms.dcim.add_manufacturer %}
-                                <li><a href="{% url 'dcim:manufacturer_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Manufacturer</a></li>
+                                <li><a href="{% url 'dcim:manufacturer_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Manufacturer</a></li>
                             {% endif %}
                             {% if perms.dcim.add_manufacturer or perms.dcim.add_platform %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'dcim:platform_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Platforms</a></li>
+                            <li><a href="{% url 'dcim:platform_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Platforms</a></li>
                             {% if perms.dcim.add_platform %}
-                                <li><a href="{% url 'dcim:platform_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Platform</a></li>
+                                <li><a href="{% url 'dcim:platform_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Platform</a></li>
                             {% endif %}
                         </ul>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/dcim/console-connections/' or request.path|startswith:'/dcim/power-connections/' or request.path|startswith:'/dcim/interface-connections/' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Connections <span class="caret"></span></a>
                         <ul class="dropdown-menu">
-                            <li><a href="{% url 'dcim:console_connections_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Console Connections</a></li>
+                            <li><a href="{% url 'dcim:console_connections_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Console Connections</a></li>
                             {% if perms.dcim.change_consoleport %}
-                                <li><a href="{% url 'dcim:console_connections_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Console Connections</a></li>
+                                <li><a href="{% url 'dcim:console_connections_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Console Connections</a></li>
                             {% endif %}
                             {% if perms.ipam.change_consoleport or perms.ipam.change_powerport %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'dcim:power_connections_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Power Connections</a></li>
+                            <li><a href="{% url 'dcim:power_connections_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Power Connections</a></li>
                             {% if perms.dcim.change_powerport %}
-                                <li><a href="{% url 'dcim:power_connections_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Power Connections</a></li>
+                                <li><a href="{% url 'dcim:power_connections_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Power Connections</a></li>
                             {% endif %}
                             {% if perms.ipam.change_powerport or perms.ipam.add_interfaceconnection %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'dcim:interface_connections_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Interface Connections</a></li>
+                            <li><a href="{% url 'dcim:interface_connections_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Interface Connections</a></li>
                             {% if perms.dcim.add_interfaceconnection %}
-                                <li><a href="{% url 'dcim:interface_connections_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Interface Connections</a></li>
+                                <li><a href="{% url 'dcim:interface_connections_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Interface Connections</a></li>
                             {% endif %}
                         </ul>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlan' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
                         <ul class="dropdown-menu">
-                            <li><a href="{% url 'ipam:ipaddress_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> IP Addresses</a></li>
+                            <li><a href="{% url 'ipam:ipaddress_list' %}"><i class="fa fa-search" aria-hidden="true"></i> IP Addresses</a></li>
                             {% if perms.ipam.add_ipaddress %}
-                                <li><a href="{% url 'ipam:ipaddress_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add an IP</a></li>
-                                <li><a href="{% url 'ipam:ipaddress_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import IPs</a></li>
+                                <li><a href="{% url 'ipam:ipaddress_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add an IP</a></li>
+                                <li><a href="{% url 'ipam:ipaddress_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import IPs</a></li>
                             {% endif %}
                             {% if perms.ipam.add_ipaddress or perms.ipam.add_prefix %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'ipam:prefix_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Prefixes</a></li>
+                            <li><a href="{% url 'ipam:prefix_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefixes</a></li>
                             {% if perms.ipam.add_prefix %}
-                                <li><a href="{% url 'ipam:prefix_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Prefix</a></li>
-                                <li><a href="{% url 'ipam:prefix_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Prefixes</a></li>
+                                <li><a href="{% url 'ipam:prefix_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Prefix</a></li>
+                                <li><a href="{% url 'ipam:prefix_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Prefixes</a></li>
                             {% endif %}
                             {% if perms.ipam.add_prefix or perms.ipam.add_aggregate %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'ipam:aggregate_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Aggregates</a></li>
+                            <li><a href="{% url 'ipam:aggregate_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Aggregates</a></li>
                             {% if perms.ipam.add_aggregate %}
-                                <li><a href="{% url 'ipam:aggregate_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add an Aggregate</a></li>
-                                <li><a href="{% url 'ipam:aggregate_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Aggregates</a></li>
+                                <li><a href="{% url 'ipam:aggregate_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add an Aggregate</a></li>
+                                <li><a href="{% url 'ipam:aggregate_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Aggregates</a></li>
                             {% endif %}
                             {% if perms.ipam.add_aggregate or perms.ipam.add_vrf %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'ipam:vrf_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VRFs</a></li>
+                            <li><a href="{% url 'ipam:vrf_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VRFs</a></li>
                             {% if perms.ipam.add_vrf %}
-                                <li><a href="{% url 'ipam:vrf_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VRF</a></li>
-                                <li><a href="{% url 'ipam:vrf_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VRFs</a></li>
+                                <li><a href="{% url 'ipam:vrf_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VRF</a></li>
+                                <li><a href="{% url 'ipam:vrf_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import VRFs</a></li>
                             {% endif %}
                             <li class="divider"></li>
-                            <li><a href="{% url 'ipam:rir_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> RIRs</a></li>
+                            <li><a href="{% url 'ipam:rir_list' %}"><i class="fa fa-search" aria-hidden="true"></i> RIRs</a></li>
                             {% if perms.ipam.add_rir %}
-                                <li><a href="{% url 'ipam:rir_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a RIR</a></li>
+                                <li><a href="{% url 'ipam:rir_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a RIR</a></li>
                             {% endif %}
                             {% if perms.ipam.add_rir or perms.ipam.add_role %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'ipam:role_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Prefix/VLAN Roles</a></li>
+                            <li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefix/VLAN Roles</a></li>
                             {% if perms.ipam.add_role %}
-                                <li><a href="{% url 'ipam:role_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Role</a></li>
+                                <li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
                             {% endif %}
                         </ul>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/ipam/vlan' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
                         <ul class="dropdown-menu">
-                            <li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
+                            <li><a href="{% url 'ipam:vlan_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLANs</a></li>
                             {% if perms.ipam.add_vlan %}
-                                <li><a href="{% url 'ipam:vlan_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN</a></li>
-                                <li><a href="{% url 'ipam:vlan_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VLANs</a></li>
+                                <li><a href="{% url 'ipam:vlan_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VLAN</a></li>
+                                <li><a href="{% url 'ipam:vlan_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import VLANs</a></li>
                             {% endif %}
                             <li class="divider"></li>
-                            <li><a href="{% url 'ipam:vlangroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLAN Groups</a></li>
+                            <li><a href="{% url 'ipam:vlangroup_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLAN Groups</a></li>
                             {% if perms.ipam.add_vlangroup %}
-                                <li><a href="{% url 'ipam:vlangroup_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
+                                <li><a href="{% url 'ipam:vlangroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
                             {% endif %}
                         </ul>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
                         <ul class="dropdown-menu">
-                            <li><a href="{% url 'circuits:provider_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Providers</a></li>
+                            <li><a href="{% url 'circuits:provider_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Providers</a></li>
                             {% if perms.circuits.add_provider %}
-                                <li><a href="{% url 'circuits:provider_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Provider</a></li>
-                                <li><a href="{% url 'circuits:provider_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Providers</a></li>
+                                <li><a href="{% url 'circuits:provider_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Provider</a></li>
+                                <li><a href="{% url 'circuits:provider_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Providers</a></li>
                             {% endif %}
                             {% if perms.circuits.add_circuit or perms.circuits.add_provider %}
                                 <li class="divider"></li>
                             {% endif %}
-                            <li><a href="{% url 'circuits:circuit_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Circuits</a></li>
+                            <li><a href="{% url 'circuits:circuit_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Circuits</a></li>
                             {% if perms.circuits.add_circuit %}
-                                <li><a href="{% url 'circuits:circuit_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Circuit</a></li>
-                                <li><a href="{% url 'circuits:circuit_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Circuits</a></li>
+                                <li><a href="{% url 'circuits:circuit_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Circuit</a></li>
+                                <li><a href="{% url 'circuits:circuit_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Circuits</a></li>
                             {% endif %}
                             <li class="divider"></li>
-                            <li><a href="{% url 'circuits:circuittype_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Circuit Types</a></li>
+                            <li><a href="{% url 'circuits:circuittype_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Circuit Types</a></li>
                             {% if perms.circuits.add_circuittype %}
-                                <li><a href="{% url 'circuits:circuittype_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Circuit Type</a></li>
+                                <li><a href="{% url 'circuits:circuittype_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Circuit Type</a></li>
                             {% endif %}
                         </ul>
                     </li>
@@ -198,14 +207,14 @@
                         <li class="dropdown{% if request.path|startswith:'/secrets/' %} active{% endif %}">
                             <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Secrets <span class="caret"></span></a>
                             <ul class="dropdown-menu">
-                                <li><a href="{% url 'secrets:secret_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Secrets</a></li>
+                                <li><a href="{% url 'secrets:secret_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Secrets</a></li>
                                 {% if perms.secrets.add_secret %}
-                                    <li><a href="{% url 'secrets:secret_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Secrets</a></li>
+                                    <li><a href="{% url 'secrets:secret_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Secrets</a></li>
                                 {% endif %}
                                 <li class="divider"></li>
-                                <li><a href="{% url 'secrets:secretrole_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Secret Roles</a></li>
+                                <li><a href="{% url 'secrets:secretrole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Secret Roles</a></li>
                                 {% if perms.secrets.add_secretrole %}
-                                    <li><a href="{% url 'secrets:secretrole_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Secret Role</a></li>
+                                    <li><a href="{% url 'secrets:secretrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Secret Role</a></li>
                                 {% endif %}
                             </ul>
                         </li>
@@ -215,12 +224,12 @@
                 <ul class="nav navbar-nav navbar-right">
                     {% if request.user.is_authenticated %}
                         {% if request.user.is_staff %}
-                            <li><a href="{% url 'admin:index' %}"><i class="glyphicon glyphicon-cog" aria-hidden="true"></i> Admin</a></li>
+                            <li><a href="{% url 'admin:index' %}"><i class="fa fa-cogs" aria-hidden="true"></i> Admin</a></li>
                         {% endif %}
-                        <li><a href="{% url 'users:profile' %}"><i class="glyphicon glyphicon-user" aria-hidden="true"></i> Profile</a></li>
-                        <li><a href="{% url 'logout' %}"><i class="glyphicon glyphicon-log-out" aria-hidden="true"></i> Log out</a></li>
+                        <li><a href="{% url 'users:profile' %}"><i class="fa fa-user" aria-hidden="true"></i> Profile</a></li>
+                        <li><a href="{% url 'logout' %}"><i class="fa fa-sign-out" aria-hidden="true"></i> Log out</a></li>
                     {% else %}
-                        <li><a href="{% url 'login' %}?next={{ request.path }}"><i class="glyphicon glyphicon-log-in" aria-hidden="true"></i> Log in</a></li>
+                        <li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li>
                     {% endif %}
                 </ul>
             </div>

+ 80 - 29
netbox/templates/circuits/circuit.html

@@ -1,24 +1,24 @@
 {% extends '_base.html' %}
 {% load helpers %}
 
-{% block title %}{{ circuit.provider }} Circuit {{ circuit.cid }}{% endblock %}
+{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}
 
 {% block content %}
 <div class="row">
     <div class="col-md-9">
         <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a></li>
-            <li><a href="{% url 'circuits:circuit_list' %}?site={{ circuit.site.slug }}">Circuits</a></li>
-            <li>{{ circuit }}</li>
+            <li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
+            <li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
+            <li>{{ circuit.cid }}</li>
         </ol>
     </div>
     <div class="col-md-3">
         <form action="{% url 'circuits:circuit_list' %}" method="get">
             <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Circuit ID" />
+                <input type="text" name="q" class="form-control" />
                 <span class="input-group-btn">
                     <button type="submit" class="btn btn-primary">
-                        <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                        <span class="fa fa-search" aria-hidden="true"></span>
                     </button>
                 </span>
             </div>
@@ -28,18 +28,18 @@
 <div class="pull-right">
     {% if perms.circuits.change_circuit %}
 		<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
-			<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+			<span class="fa fa-pencil" aria-hidden="true"></span>
 			Edit this circuit
 		</a>
     {% endif %}
     {% if perms.circuits.delete_circuit %}
 		<a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
-			<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+			<span class="fa fa-trash" aria-hidden="true"></span>
 			Delete this circuit
 		</a>
     {% endif %}
 </div>
-<h1>{{ circuit.provider }} Circuit {{ circuit.cid }}</h1>
+<h1>{{ circuit.provider }} - {{ circuit.cid }}</h1>
 <div class="row">
 	<div class="col-md-6">
         <div class="panel panel-default">
@@ -58,40 +58,48 @@
                     <td>{{ circuit.cid }}</td>
                 </tr>
                 <tr>
-                    <td>Site</td>
-                    <td>
-                        <a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a>
-                    </td>
+                    <td>Type</td>
+                    <td><a href="{{ circuit.type.get_absolute_url }}">{{ circuit.type }}</a></td>
                 </tr>
                 <tr>
-                    <td>Termination</td>
+                    <td>Tenant</td>
                     <td>
-                        {% if circuit.interface %}
-                            <span><a href="{% url 'dcim:device' pk=circuit.interface.device.pk %}">{{ circuit.interface.device }}</a> {{ circuit.interface }}</span>
+                        {% if circuit.tenant %}
+                            <a href="{{ circuit.tenant.get_absolute_url }}">{{ circuit.tenant }}</a>
                         {% else %}
-                            <span class="text-muted">Not defined</span>
+                            <span class="text-muted">None</span>
                         {% endif %}
                     </td>
                 </tr>
                 <tr>
                     <td>Install Date</td>
-                    <td>{{ circuit.install_date }}</td>
+                    <td>
+                        {% if circuit.install_date %}
+                            {{ circuit.install_date }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Port Speed</td>
-                    <td>{{ circuit.port_speed_human }}</td>
+                    <td>
+                        {% if circuit.port_speed %}
+                            {{ circuit.port_speed_human }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Commit Rate</td>
-                    <td>{% if circuit.commit_rate %}{{ circuit.commit_rate_human }}{% else %}<span class="text-muted">N/A</span>{% endif %}</td>
-                </tr>
-                <tr>
-                    <td>Cross-Connect</td>
-                    <td>{{ circuit.xconnect_id }}</td>
-                </tr>
-                <tr>
-                    <td>Patch Panel/Port</td>
-                    <td>{{ circuit.pp_info }}</td>
+                    <td>
+                        {% if circuit.commit_speed %}
+                            {{ circuit.commit_speed_human }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Created</td>
@@ -105,12 +113,55 @@
         </div>
 	</div>
 	<div class="col-md-6">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Termination</strong>
+            </div>
+            <table class="table table-hover panel-body">
+                <tr>
+                    <td>Site</td>
+                    <td>
+                        <a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Termination</td>
+                    <td>
+                        {% if circuit.interface %}
+                            <span><a href="{% url 'dcim:device' pk=circuit.interface.device.pk %}">{{ circuit.interface.device }}</a> {{ circuit.interface }}</span>
+                        {% else %}
+                            <span class="text-muted">Not defined</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Cross-Connect</td>
+                    <td>
+                        {% if circuit.xconnect_id %}
+                            {{ circuit.xconnect_id }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Patch Panel/Port</td>
+                    <td>
+                        {% if circuit.pp_info %}
+                            {{ circuit.pp_info }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Comments</strong>
             </div>
             <div class="panel-body">
-                {% if circuit.comments  %}
+                {% if circuit.comments %}
                     {{ circuit.comments|gfm }}
                 {% else %}
                     <span class="text-muted">None</span>

+ 8 - 2
netbox/templates/circuits/circuit_edit.html

@@ -9,13 +9,19 @@
             {% render_field form.provider %}
             {% render_field form.cid %}
             {% render_field form.type %}
+            {% render_field form.tenant %}
             {% render_field form.install_date %}
-            {% render_field form.port_speed %}
-            {% render_field form.commit_rate %}
             {% render_field form.xconnect_id %}
             {% render_field form.pp_info %}
         </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Bandwidth</strong></div>
+        <div class="panel-body">
+            {% render_field form.port_speed %}
+            {% render_field form.commit_rate %}
+        </div>
+    </div>
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Termination</strong></div>
         <div class="panel-body">

+ 6 - 1
netbox/templates/circuits/circuit_import.html

@@ -43,6 +43,11 @@
 					<td>Circuit type</td>
 					<td>Transit</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Strickland Propane</td>
+				</tr>
 				<tr>
 					<td>Site</td>
 					<td>Site name</td>
@@ -76,7 +81,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>IC-603122,TeliaSonera,Transit,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
+		<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
 	</div>
 </div>
 {% endblock %}

+ 2 - 18
netbox/templates/circuits/circuit_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.circuits.add_circuit %}
 		<a href="{% url 'circuits:circuit_add' %}" class="btn btn-primary">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add a circuit
 		</a>
     {% endif %}
@@ -19,23 +19,7 @@
         {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %}
     </div>
     <div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<strong>Search</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'circuits:circuit_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
     </div>
 </div>

+ 1 - 1
netbox/templates/circuits/circuittype_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.circuits.add_circuittype %}
         <a href="{% url 'circuits:circuittype_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a circuit type
         </a>
     {% endif %}

+ 55 - 13
netbox/templates/circuits/provider.html

@@ -6,27 +6,41 @@
 
 {% block content %}
 <div class="row">
-    <div class="col-md-12">
+    <div class="col-md-9">
         <ol class="breadcrumb">
             <li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
             <li>{{ provider }}</li>
         </ol>
     </div>
+    <div class="col-md-3">
+        <form action="{% url 'circuits:provider_list' %}" method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" />
+                <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">
-    <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider_graphs' pk=provider.pk %}" title="Show graphs">
-        <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
-        Graphs
-    </button>
+    {% if show_graphs %}
+        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider_graphs' pk=provider.pk %}" title="Show graphs">
+            <i class="fa fa-signal" aria-hidden="true"></i>
+            Graphs
+        </button>
+    {% endif %}
     {% if perms.circuits.change_provider %}
 		<a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
-			<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+			<span class="fa fa-pencil" aria-hidden="true"></span>
 			Edit this provider
 		</a>
     {% endif %}
     {% if perms.circuits.delete_provider %}
 		<a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
-			<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+			<span class="fa fa-trash" aria-hidden="true"></span>
 			Delete this provider
 		</a>
     {% endif %}
@@ -41,25 +55,53 @@
             <table class="table table-hover panel-body">
                 <tr>
                     <td>ASN</td>
-                    <td>{{ provider.asn }}</td>
+                    <td>
+                        {% if provider.asn %}
+                            {{ provider.asn }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Account</td>
-                    <td>{{ provider.account }}</td>
+                    <td>
+                        {% if provider.account %}
+                            {{ provider.account }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Customer Portal</td>
                     <td>
-                        <a href="{{ provider.portal_url }}">{{ provider.portal_url }}</a>
+                        {% if provider.portal_url %}
+                            <a href="{{ provider.portal_url }}">{{ provider.portal_url }}</a>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
                     </td>
                 </tr>
                 <tr>
                     <td>NOC Contact</td>
-                    <td>{{ provider.noc_contact|linebreaksbr }}</td>
+                    <td>
+                        {% if provider.noc_contact %}
+                            {{ provider.noc_contact|linebreaksbr }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Admin Contact</td>
-                    <td>{{ provider.admin_contact|linebreaksbr }}</td>
+                    <td>
+                        {% if provider.admin_contact %}
+                            {{ provider.admin_contact|linebreaksbr }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Created</td>
@@ -76,7 +118,7 @@
                 <strong>Comments</strong>
             </div>
             <div class="panel-body">
-                {% if provider.comments  %}
+                {% if provider.comments %}
                     {{ provider.comments|gfm }}
                 {% else %}
                     <span class="text-muted">None</span>

+ 2 - 18
netbox/templates/circuits/provider_list.html

@@ -6,7 +6,7 @@
 <div class="pull-right">
     {% if perms.circuits.add_provider %}
 		<a href="{% url 'circuits:provider_add' %}" class="btn btn-primary">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add a provider
 		</a>
     {% endif %}
@@ -18,23 +18,7 @@
         {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
     </div>
     <div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<strong>Search</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'circuits:provider_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
     </div>
 </div>

+ 1 - 1
netbox/templates/dcim/console_connections_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.change_consoleport %}
         <a href="{% url 'dcim:console_connections_import' %}" class="btn btn-info">
-            <span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+            <span class="fa fa-download" aria-hidden="true"></span>
             Import connections
         </a>
     {% endif %}

+ 15 - 5
netbox/templates/dcim/device.html

@@ -14,6 +14,16 @@
                 <strong>Device</strong>
             </div>
             <table class="table table-hover panel-body">
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if device.tenant %}
+                            <a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                     <td>Site</td>
                     <td>
@@ -55,7 +65,7 @@
                         {% if device.serial %}
                             <span>{{ device.serial }}</span>
                         {% else %}
-                            <span class="text-muted">Not defined</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -86,7 +96,7 @@
                         {% if device.platform %}
                             <span>{{ device.platform }}</span>
                         {% else %}
-                            <span class="text-warning">Not assigned</span>
+                            <span class="text-muted">None</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -111,7 +121,7 @@
                                 <span>(NAT: {{ device.primary_ip4.nat_outside.address.ip }})</span>
                             {% endif %}
                         {% else %}
-                            <span class="text-muted">Not defined</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -126,7 +136,7 @@
                                 <span>(NAT: {{ device.primary_ip6.nat_outside.address.ip }})</span>
                             {% endif %}
                         {% else %}
-                            <span class="text-muted">Not defined</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -257,7 +267,7 @@
                 <strong>Comments</strong>
             </div>
             <div class="panel-body">
-                {% if device.comments  %}
+                {% if device.comments %}
                     {{ device.comments|gfm }}
                 {% else %}
                     <span class="text-muted">None</span>

+ 1 - 0
netbox/templates/dcim/device_bulk_edit.html

@@ -9,6 +9,7 @@
             <td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
             <td>{{ device.device_type }}</td>
             <td>{{ device.device_role }}</td>
+            <td>{{ device.tenant }}</td>
             <td>{{ device.serial }}</td>
         </tr>
     {% endfor %}

+ 1 - 0
netbox/templates/dcim/device_edit.html

@@ -7,6 +7,7 @@
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.device_role %}
+            {% render_field form.tenant %}
         </div>
     </div>
     <div class="panel panel-default">

+ 6 - 1
netbox/templates/dcim/device_import.html

@@ -36,6 +36,11 @@
 					<td>Functional role of device</td>
 					<td>ToR Switch</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 					<td>Device manufacturer</td>
 					<td>Hardware manufacturer</td>
@@ -79,7 +84,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
+		<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
 	</div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/dcim/device_inventory.html

@@ -107,7 +107,7 @@
         </div>
         {% if perms.dcim.add_module %}
             <a href="{% url 'dcim:module_add' pk=device.pk %}" class="btn btn-success">
-                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                <span class="fa fa-plus" aria-hidden="true"></span>
                 Add a Module
             </a>
         {% endif %}

+ 3 - 20
netbox/templates/dcim/device_list.html

@@ -7,11 +7,11 @@
 <div class="pull-right">
     {% if perms.dcim.add_device %}
         <a href="{% url 'dcim:device_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a device
         </a>
         <a href="{% url 'dcim:device_import' %}" class="btn btn-info">
-            <span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+            <span class="fa fa-download" aria-hidden="true"></span>
             Import devices
         </a>
     {% endif %}
@@ -23,24 +23,7 @@
         {% include 'dcim/inc/device_table.html' with bulk_edit_url='dcim:device_bulk_edit' bulk_delete_url='dcim:device_bulk_delete' %}
     </div>
     <div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<span class="glyphicon glyphicon-search" aria-hidden="true"></span> 
-				<strong>Search</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'dcim:device_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="q" class="form-control" placeholder="Name or serial" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
     </div>
 </div>

+ 1 - 1
netbox/templates/dcim/devicerole_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.add_devicerole %}
         <a href="{% url 'dcim:devicerole_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a device role
         </a>
     {% endif %}

+ 2 - 2
netbox/templates/dcim/devicetype.html

@@ -19,13 +19,13 @@
     <div class="pull-right">
       {% if perms.dcim.change_devicetype %}
             <a href="{% url 'dcim:devicetype_edit' pk=devicetype.pk %}" class="btn btn-warning">
-              <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+              <span class="fa fa-pencil" aria-hidden="true"></span>
               Edit this device type
             </a>
       {% endif %}
       {% if perms.dcim.delete_devicetype %}
           <a href="{% url 'dcim:devicetype_delete' pk=devicetype.pk %}" class="btn btn-danger">
-          	<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+          	<span class="fa fa-trash" aria-hidden="true"></span>
           	Delete this device type
           </a>
       {% endif %}

+ 1 - 1
netbox/templates/dcim/devicetype_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.add_devicetype %}
         <a href="{% url 'dcim:devicetype_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a device type
         </a>
     {% endif %}

+ 2 - 2
netbox/templates/dcim/inc/_device_header.html

@@ -16,10 +16,10 @@
     <div class="col-md-3">
         <form action="{% url 'dcim:device_list' %}" method="get">
             <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Device name or serial" />
+                <input type="text" name="q" class="form-control" placeholder="Search devices" />
                 <span class="input-group-btn">
                     <button type="submit" class="btn btn-primary">
-                        <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                        <span class="fa fa-search" aria-hidden="true"></span>
                     </button>
                 </span>
             </div>

+ 6 - 4
netbox/templates/dcim/inc/_interface.html

@@ -34,10 +34,12 @@
         </td>
     {% endif %}
     <td class="text-right">
-        {% if iface.circuit or iface.connection %}
-            <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface_graphs' pk=iface.pk %}" title="Show graphs">
-                <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
-            </button>
+        {% if show_graphs %}
+            {% if iface.circuit or iface.connection %}
+                <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface_graphs' pk=iface.pk %}" title="Show graphs">
+                    <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
+                </button>
+            {% endif %}
         {% endif %}
         {% if perms.dcim.change_interface %}
             {% if iface.is_physical %}

+ 1 - 1
netbox/templates/dcim/interface_connections_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.add_interfaceconnection %}
         <a href="{% url 'dcim:interface_connections_import' %}" class="btn btn-info">
-            <span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+            <span class="fa fa-download" aria-hidden="true"></span>
             Import connections
         </a>
     {% endif %}

+ 1 - 1
netbox/templates/dcim/manufacturer_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.add_manufacturer %}
         <a href="{% url 'dcim:manufacturer_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a manufacturer
         </a>
     {% endif %}

+ 1 - 1
netbox/templates/dcim/platform_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.add_platform %}
         <a href="{% url 'dcim:platform_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a platform
         </a>
     {% endif %}

+ 1 - 1
netbox/templates/dcim/power_connections_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.change_powerport %}
         <a href="{% url 'dcim:power_connections_import' %}" class="btn btn-info">
-            <span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+            <span class="fa fa-download" aria-hidden="true"></span>
             Import connections
         </a>
     {% endif %}

+ 19 - 9
netbox/templates/dcim/rack.html

@@ -8,18 +8,18 @@
 <div class="row">
     <div class="col-md-9">
         <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:site' slug=rack.site.slug %}">{{ rack.site }}</a></li>
-            <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">Racks</a></li>
+            <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
+            <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
             <li>{{ rack }}</li>
         </ol>
     </div>
     <div class="col-md-3">
         <form action="{% url 'dcim:rack_list' %}" method="get">
             <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Rack name or ID" />
+                <input type="text" name="q" class="form-control" placeholder="Search racks" />
                 <span class="input-group-btn">
                     <button type="submit" class="btn btn-primary">
-                        <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                        <span class="fa fa-search" aria-hidden="true"></span>
                     </button>
                 </span>
             </div>
@@ -29,25 +29,25 @@
 <div class="pull-right">
     {% if prev_rack %}
         <a href="{% url 'dcim:rack' pk=prev_rack.pk %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-chevron-left" aria-hidden="true"></span>
+            <span class="fa fa-chevron-left" aria-hidden="true"></span>
             Previous Rack
         </a>
     {% endif %}
     {% if next_rack %}
         <a href="{% url 'dcim:rack' pk=next_rack.pk %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-chevron-right" aria-hidden="true"></span>
+            <span class="fa fa-chevron-right" aria-hidden="true"></span>
             Next Rack
         </a>
     {% endif %}
     {% if perms.dcim.change_rack %}
 		<a href="{% url 'dcim:rack_edit' pk=rack.pk %}" class="btn btn-warning">
-			<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+			<span class="fa fa-pencil" aria-hidden="true"></span>
 			Edit this rack
 		</a>
     {% endif %}
     {% if perms.dcim.delete_rack %}
 		<a href="{% url 'dcim:rack_delete' pk=rack.pk %}" class="btn btn-danger">
-			<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+			<span class="fa fa-trash" aria-hidden="true"></span>
 			Delete this rack
 		</a>
     {% endif %}
@@ -81,6 +81,16 @@
                     <td>
                         {% if rack.facility_id %}
                             <span>{{ rack.facility_id }}</span>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if rack.tenant %}
+                            <a href="{{ rack.tenant.get_absolute_url }}">{{ rack.tenant }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
                         {% endif %}
@@ -146,7 +156,7 @@
                 <strong>Comments</strong>
             </div>
             <div class="panel-body">
-                {% if rack.comments  %}
+                {% if rack.comments %}
                     {{ rack.comments|gfm }}
                 {% else %}
                     <span class="text-muted">None</span>

+ 1 - 0
netbox/templates/dcim/rack_bulk_edit.html

@@ -9,6 +9,7 @@
             <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
             <td>{{ rack.facility_id }}</td>
             <td>{{ rack.site }}</td>
+            <td>{{ rack.tenant }}</td>
             <td>{{ rack.u_height }}</td>
         </tr>
     {% endfor %}

+ 1 - 0
netbox/templates/dcim/rack_edit.html

@@ -9,6 +9,7 @@
             {% render_field form.group %}
             {% render_field form.name %}
             {% render_field form.facility_id %}
+            {% render_field form.tenant %}
             {% render_field form.u_height %}
         </div>
     </div>

+ 6 - 1
netbox/templates/dcim/rack_import.html

@@ -48,6 +48,11 @@
 					<td>Rack ID assigned by the facility (optional)</td>
 					<td>J12.100</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 					<td>Height</td>
 					<td>Height in rack units</td>
@@ -56,7 +61,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>DC-4,Cage 1400,R101,J12.100,42</pre>
+		<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,42</pre>
 	</div>
 </div>
 {% endblock %}

+ 3 - 20
netbox/templates/dcim/rack_list.html

@@ -7,11 +7,11 @@
 <div class="pull-right">
     {% if perms.dcim.add_rack %}
         <a href="{% url 'dcim:rack_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a rack
         </a>
         <a href="{% url 'dcim:rack_import' %}" class="btn btn-info">
-            <span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+            <span class="fa fa-download" aria-hidden="true"></span>
             Import racks
         </a>
     {% endif %}
@@ -23,24 +23,7 @@
         {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rack_bulk_edit' bulk_delete_url='dcim:rack_bulk_delete' %}
     </div>
     <div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<span class="glyphicon glyphicon-search" aria-hidden="true"></span> 
-				<strong>Search</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'dcim:rack_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
     </div>
 </div>

+ 1 - 1
netbox/templates/dcim/rackgroup_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.add_rackgroup %}
         <a href="{% url 'dcim:rackgroup_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a rack group
         </a>
     {% endif %}

+ 63 - 47
netbox/templates/dcim/site.html

@@ -16,10 +16,10 @@
     <div class="col-md-3">
         <form action="{% url 'dcim:site_list' %}" method="get">
             <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search" />
+                <input type="text" name="q" class="form-control" placeholder="Search sites" />
                 <span class="input-group-btn">
                     <button type="submit" class="btn btn-primary">
-                        <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                        <span class="fa fa-search" aria-hidden="true"></span>
                     </button>
                 </span>
             </div>
@@ -27,38 +27,62 @@
     </div>
 </div>
 <div class="pull-right">
-    <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site_graphs' pk=site.pk %}" title="Show graphs">
-        <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
-        Graphs
-    </button>
+    {% if show_graphs %}
+        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site_graphs' pk=site.pk %}" title="Show graphs">
+            <i class="fa fa-signal" aria-hidden="true"></i>
+            Graphs
+        </button>
+    {% endif %}
     {% if perms.dcim.change_site %}
 		<a href="{% url 'dcim:site_edit' slug=site.slug %}" class="btn btn-warning">
-			<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+			<span class="fa fa-pencil" aria-hidden="true"></span>
 			Edit this site
 		</a>
     {% endif %}
     {% if perms.dcim.delete_site %}
 		<a href="{% url 'dcim:site_delete' slug=site.slug %}" class="btn btn-danger">
-			<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+			<span class="fa fa-trash" aria-hidden="true"></span>
 			Delete this site
 		</a>
     {% endif %}
 </div>
 <h1>{{ site.name }}</h1>
 <div class="row">
-	<div class="col-md-6">
+	<div class="col-md-7">
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Site</strong>
             </div>
             <table class="table table-hover panel-body">
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if site.tenant %}
+                            <a href="{{ site.tenant.get_absolute_url }}">{{ site.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                     <td>Facility</td>
-                    <td>{{ site.facility }}</td>
+                    <td>
+                        {% if site.facility %}
+                            {{ site.facility }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>AS Number</td>
-                    <td>{{ site.asn }}</td>
+                    <td>
+                        {% if site.asn %}
+                            {{ site.asn }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Physical Address</td>
@@ -69,8 +93,10 @@
                                     <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 %}
-                        <span>{{ site.physical_address|linebreaksbr }}</span>
                     </td>
                 </tr>
                 <tr>
@@ -79,7 +105,7 @@
                         {% if site.shipping_address %}
                             <span>{{ site.shipping_address|linebreaksbr }}</span>
                         {% else %}
-                            <span class="text-muted">See physical address</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -98,7 +124,7 @@
                 <strong>Comments</strong>
             </div>
             <div class="panel-body">
-                {% if site.comments  %}
+                {% if site.comments %}
                     {{ site.comments|gfm }}
                 {% else %}
                     <span class="text-muted">None</span>
@@ -106,43 +132,33 @@
             </div>
         </div>
     </div>
-    <div class="col-md-6">
+    <div class="col-md-5">
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Stats</strong>
             </div>
-            <table class="table table-hover panel-body">
-                <tr>
-                    <td>Racks</td>
-                    <td>
-                        <a href="{% url 'dcim:rack_list' %}?site={{ site.slug }}">{{ stats.rack_count }}</a>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Devices</td>
-                    <td>
-                        <a href="{% url 'dcim:device_list' %}?site={{ site.slug }}">{{ stats.device_count }}</a>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Prefixes</td>
-                    <td>
-                        <a href="{% url 'ipam:prefix_list' %}?site={{ site.slug }}">{{ stats.prefix_count }}</a>
-                    </td>
-                </tr>
-                <tr>
-                    <td>VLANs</td>
-                    <td>
-                        <a href="{% url 'ipam:vlan_list' %}?site={{ site.slug }}">{{ stats.vlan_count }}</a>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Circuits</td>
-                    <td>
-                        <a href="{% url 'circuits:circuit_list' %}?site={{ site.slug }}">{{ stats.circuit_count }}</a>
-                    </td>
-                </tr>
-            </table>
+            <div class="row panel-body">
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'dcim:rack_list' %}?site={{ site.slug }}" class="btn {% if stats.rack_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.rack_count }}</a></h2>
+                    <p>Racks</p>
+                </div>
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'dcim:device_list' %}?site={{ site.slug }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
+                    <p>Devices</p>
+                </div>
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'ipam:prefix_list' %}?site={{ site.slug }}" class="btn {% if stats.prefix_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
+                    <p>Prefixes</p>
+                </div>
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'ipam:vlan_list' %}?site={{ site.slug }}" class="btn {% if stats.vlan_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2>
+                    <p>VLANs</p>
+                </div>
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'circuits:circuit_list' %}?site={{ site.slug }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
+                    <p>Circuits</p>
+                </div>
+            </div>
         </div>
         <div class="panel panel-default">
             <div class="panel-heading">

+ 13 - 0
netbox/templates/dcim/site_bulk_edit.html

@@ -0,0 +1,13 @@
+{% extends 'utilities/bulk_edit_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Site Bulk Edit{% endblock %}
+
+{% block select_objects_table %}
+    {% for site in selected_objects %}
+        <tr>
+            <td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site.slug }}</a></td>
+            <td>{{ site.tenant }}</td>
+        </tr>
+    {% endfor %}
+{% endblock %}

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

@@ -7,6 +7,7 @@
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.slug %}
+            {% render_field form.tenant %}
             {% render_field form.facility %}
             {% render_field form.asn %}
             {% render_field form.physical_address %}

+ 6 - 1
netbox/templates/dcim/site_import.html

@@ -38,6 +38,11 @@
 					<td>URL-friendly name</td>
 					<td>ash4-south</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 					<td>Facility</td>
 					<td>Name of the hosting facility (optional)</td>
@@ -51,7 +56,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>ASH-4 South,ash4-south,Equinix DC6,65000</pre>
+		<pre>ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000</pre>
 	</div>
 </div>
 {% endblock %}

+ 8 - 21
netbox/templates/dcim/site_list.html

@@ -1,5 +1,4 @@
 {% extends '_base.html' %}
-{% load render_table from django_tables2 %}
 
 {% block title %}Sites{% endblock %}
 
@@ -7,36 +6,24 @@
 <div class="pull-right">
     {% if perms.dcim.add_site %}
 		<a href="{% url 'dcim:site_add' %}" class="btn btn-primary">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add a site
 		</a>
+        <a href="{% url 'dcim:site_import' %}" class="btn btn-info">
+            <span class="fa fa-download" aria-hidden="true"></span>
+            Import sites
+        </a>
     {% endif %}
     {% include 'inc/export_button.html' with obj_type='sites' %}
 </div>
 <h1>Sites</h1>
 <div class="row">
 	<div class="col-md-9">
-        {% render_table table 'table.html' %}
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' %}
     </div>
     <div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<span class="glyphicon glyphicon-search" aria-hidden="true"></span> 
-				<strong>Search</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'dcim:site_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
+		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 39 - 22
netbox/templates/home.html

@@ -6,10 +6,10 @@
 	<div class="col-md-4">
 		<form action="{% url 'dcim:device_list' %}" method="get">
 			<div class="input-group input-group-lg">
-				<input type="text" name="q" placeholder="Device name or serial" class="form-control" />
+				<input type="text" name="q" placeholder="Search devices" class="form-control" />
 				<span class="input-group-btn">
 					<button type="submit" class="btn btn-primary">
-						<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+						<span class="fa fa-search" aria-hidden="true"></span>
 						Devices
 					</button>
 				</span>
@@ -20,11 +20,11 @@
 	<div class="col-md-4">
 		<form action="{% url 'ipam:prefix_list' %}" method="get">
 			<div class="input-group input-group-lg">
-				<input type="text" name="q" placeholder="IP or network" class="form-control" />
+				<input type="text" name="q" placeholder="Search prefixes" class="form-control" />
 				<span class="input-group-btn">
 					<button type="submit" class="btn btn-primary">
-						<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-						IP
+						<span class="fa fa-search" aria-hidden="true"></span>
+						Prefixes
 					</button>
 				</span>
 			</div>
@@ -34,10 +34,10 @@
 	<div class="col-md-4">
 		<form action="{% url 'circuits:circuit_list' %}" method="get">
 			<div class="input-group input-group-lg">
-				<input type="text" name="q" placeholder="Circuit ID" class="form-control" />
+				<input type="text" name="q" placeholder="Search circuits" class="form-control" />
 				<span class="input-group-btn">
 					<button type="submit" class="btn btn-primary">
-						<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+						<span class="fa fa-search" aria-hidden="true"></span>
 						Circuits
 					</button>
 				</span>
@@ -50,7 +50,7 @@
     <div class="col-md-4">
         <div class="panel panel-default">
             <div class="panel-heading">
-                <strong>DCIM</strong>
+                <strong>Organization</strong>
             </div>
             <div class="list-group">
                 <div class="list-group-item">
@@ -58,6 +58,18 @@
                     <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>
                 </div>
+                <div class="list-group-item">
+                    <span class="badge pull-right">{{ stats.tenant_count }}</span>
+                    <h4 class="list-group-item-heading"><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></h4>
+                    <p class="list-group-item-text text-muted">Customers or departments</p>
+                </div>
+            </div>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>DCIM</strong>
+            </div>
+            <div class="list-group">
                 <div class="list-group-item">
                     <span class="badge pull-right">{{ stats.rack_count }}</span>
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:rack_list' %}">Racks</a></h4>
@@ -79,20 +91,6 @@
                 </div>
             </div>
         </div>
-        {% if perms.secrets %}
-            <div class="panel panel-default">
-                <div class="panel-heading">
-                    <strong>Secrets</strong>
-                </div>
-                <div class="list-group">
-                    <div class="list-group-item">
-                        <span class="badge pull-right">{{ stats.secret_count }}</span>
-                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
-                        <p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
-                    </div>
-                </div>
-            </div>
-        {% endif %}
     </div>
     <div class="col-md-4">
         <div class="panel panel-default">
@@ -100,6 +98,11 @@
                 <strong>IPAM</strong>
             </div>
             <div class="list-group">
+                <div class="list-group-item">
+                    <span class="badge pull-right">{{ stats.vrf_count }}</span>
+                    <h4 class="list-group-item-heading"><a href="{% url 'ipam:vrf_list' %}">VRFs</a></h4>
+                    <p class="list-group-item-text text-muted">Virtual routing and forwarding tables</p>
+                </div>
                 <div class="list-group-item">
                     <span class="badge pull-right">{{ stats.aggregate_count }}</span>
                     <h4 class="list-group-item-heading"><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></h4>
@@ -141,6 +144,20 @@
         </div>
     </div>
     <div class="col-md-4">
+        {% if perms.secrets %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Secrets</strong>
+                </div>
+                <div class="list-group">
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.secret_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
+                        <p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
+                    </div>
+                </div>
+            </div>
+        {% endif %}
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Recent Activity</strong>

+ 1 - 1
netbox/templates/import_success.html

@@ -7,7 +7,7 @@
 <h1>Import Completed</h1>
 {% render_table table %}
 <a href="{{ request.path }}" class="btn btn-primary">
-	<span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+	<span class="fa fa-download" aria-hidden="true"></span>
 	Import more
 </a>
 {% endblock %}

+ 2 - 2
netbox/templates/inc/export_button.html

@@ -1,7 +1,7 @@
 {% if export_templates %}
     <div class="btn-group">
         <button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-            <span class="glyphicon glyphicon-export" aria-hidden="true"></span>
+            <span class="fa fa-upload" aria-hidden="true"></span>
             Export {{ obj_type }} <span class="caret"></span>
         </button>
         <ul class="dropdown-menu">
@@ -14,7 +14,7 @@
     </div>
 {% else %}
     <a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export" class="btn btn-success">
-        <span class="glyphicon glyphicon-export" aria-hidden="true"></span>
+        <span class="fa fa-upload" aria-hidden="true"></span>
         Export {{ obj_type }}
     </a>
 {% endif %}

+ 2 - 2
netbox/templates/inc/filter_panel.html

@@ -2,7 +2,7 @@
 
 <div class="panel panel-default">
     <div class="panel-heading">
-        <span class="glyphicon glyphicon-filter" aria-hidden="true"></span> 
+        <span class="fa fa-filter" aria-hidden="true"></span>
         <strong>Filter</strong>
     </div>
     <div class="panel-body">
@@ -19,7 +19,7 @@
             {% endfor %}
             <div class="text-right">
                 <button type="submit" class="btn btn-primary">
-                    <span class="glyphicon glyphicon-search" aria-hidden="true"></span> Apply filters
+                    <span class="fa fa-search" aria-hidden="true"></span> Apply filters
                 </button>
             </div>
         </form>

+ 18 - 0
netbox/templates/inc/search_panel.html

@@ -0,0 +1,18 @@
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <span class="fa fa-search" aria-hidden="true"></span>
+        <strong>Search</strong>
+    </div>
+    <div class="panel-body">
+        <form action="." method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" placeholder="Search" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
+                <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>

+ 18 - 6
netbox/templates/ipam/aggregate.html

@@ -5,24 +5,36 @@
 
 {% block content %}
 <div class="row">
-    <div class="col-md-12">
+    <div class="col-md-9">
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></li>
             <li><a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a></li>
             <li>{{ aggregate }}</li>
         </ol>
     </div>
+    <div class="col-md-3">
+        <form action="{% url 'ipam:aggregate_list' %}" method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" placeholder="Search aggregates" />
+                <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.ipam.change_aggregate %}
         <a href="{% url 'ipam:aggregate_edit' pk=aggregate.pk %}" class="btn btn-warning">
-            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+            <span class="fa fa-pencil" aria-hidden="true"></span>
             Edit this aggregate
         </a>
     {% endif %}
     {% if perms.ipam.delete_aggregate %}
         <a href="{% url 'ipam:aggregate_delete' pk=aggregate.pk %}" class="btn btn-danger">
-            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+            <span class="fa fa-trash" aria-hidden="true"></span>
             Delete this aggregate
         </a>
     {% endif %}
@@ -51,17 +63,17 @@
                         {% if aggregate.date_added %}
                             <span>{{ aggregate.date_added }}</span>
                         {% else %}
-                            <span class="text-muted">Not defined</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
                 <tr>
                     <td>Description</td>
                     <td>
-                        {% if aggregate.description  %}
+                        {% if aggregate.description %}
                             <span>{{ aggregate.description }}</span>
                         {% else %}
-                            <span class="text-muted">None</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>

+ 2 - 1
netbox/templates/ipam/aggregate_list.html

@@ -8,7 +8,7 @@
 <div class="pull-right">
     {% if perms.ipam.add_aggregate %}
 		<a href="{% url 'ipam:aggregate_add' %}" class="btn btn-primary">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add an aggregate
 		</a>
     {% endif %}
@@ -22,6 +22,7 @@
         <p class="text-right">IPv6 total: <strong>{{ ipv6_total|intcomma }} /64s</strong></p>
 	</div>
 	<div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>

+ 8 - 10
netbox/templates/ipam/inc/prefix_header.html

@@ -1,11 +1,9 @@
 <div class="row">
     <div class="col-md-9">
         <ol class="breadcrumb">
-            {% if prefix.site %}
-                <li><a href="{% url 'dcim:site' slug=prefix.site.slug %}">{{ prefix.site }}</a></li>
-                <li><a href="{% url 'ipam:prefix_list' %}?site={{ prefix.site.slug }}">Prefixes</a></li>
-            {% else %}
-                <li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
+            <li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
+            {% if prefix.vrf %}
+                <li><a href="{% url 'ipam:prefix_list' %}?vrf={{ prefix.vrf.pk }}">{{ prefix.vrf }}</a></li>
             {% endif %}
             <li>{{ prefix }}</li>
         </ol>
@@ -13,10 +11,10 @@
     <div class="col-md-3">
         <form action="{% url 'ipam:prefix_list' %}" method="get">
             <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Network or IP" />
+                <input type="text" name="q" class="form-control" placeholder="Search prefixes" />
                 <span class="input-group-btn">
                     <button type="submit" class="btn btn-primary">
-                        <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                        <span class="fa fa-search" aria-hidden="true"></span>
                     </button>
                 </span>
             </div>
@@ -26,19 +24,19 @@
 <div class="pull-right">
     {% if perms.ipam.add_ipaddress %}
 		<a href="{% url 'ipam:ipaddress_add' %}?address={{ prefix.prefix }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-success">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add an IP Address
 		</a>
     {% endif %}
     {% if perms.ipam.change_prefix %}
 		<a href="{% url 'ipam:prefix_edit' pk=prefix.pk %}" class="btn btn-warning">
-			<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+			<span class="fa fa-pencil" aria-hidden="true"></span>
 			Edit this prefix
 		</a>
     {% endif %}
     {% if perms.ipam.delete_prefix %}
 		<a href="{% url 'ipam:prefix_delete' pk=prefix.pk %}" class="btn btn-danger">
-			<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+			<span class="fa fa-trash" aria-hidden="true"></span>
 			Delete this prefix
 		</a>
     {% endif %}

+ 24 - 12
netbox/templates/ipam/ipaddress.html

@@ -7,21 +7,20 @@
 <div class="row">
     <div class="col-md-9">
         <ol class="breadcrumb">
-            {% for p in parent_prefixes %}
-                <li><a href="{% url 'ipam:prefix' pk=p.pk %}">{{ p }}</a></li>
-            {% empty %}
-                <li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
-            {% endfor %}
-            <li>{{ ipaddress.address.ip }}</li>
+            <li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
+            {% if ipaddress.vrf %}
+                <li><a href="{% url 'ipam:ipaddress_list' %}?vrf={{ ipaddress.vrf.pk }}">{{ ipaddress.vrf }}</a></li>
+            {% endif %}
+            <li>{{ ipaddress }}</li>
         </ol>
     </div>
     <div class="col-md-3">
         <form action="{% url 'ipam:ipaddress_list' %}" method="get">
             <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="IP address" />
+                <input type="text" name="q" class="form-control" placeholder="Search IPs" />
                 <span class="input-group-btn">
                     <button type="submit" class="btn btn-primary">
-                        <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                        <span class="fa fa-search" aria-hidden="true"></span>
                     </button>
                 </span>
             </div>
@@ -31,13 +30,13 @@
 <div class="pull-right">
     {% if perms.ipam.change_ipaddress %}
         <a href="{% url 'ipam:ipaddress_edit' pk=ipaddress.pk %}" class="btn btn-warning">
-            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+            <span class="fa fa-pencil" aria-hidden="true"></span>
             Edit this IP
         </a>
     {% endif %}
     {% if perms.ipam.delete_ipaddress %}
         <a href="{% url 'ipam:ipaddress_delete' pk=ipaddress.pk %}" class="btn btn-danger">
-            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+            <span class="fa fa-trash" aria-hidden="true"></span>
             Delete this IP
         </a>
     {% endif %}
@@ -64,13 +63,26 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if ipaddress.tenant %}
+                            <a href="{{ ipaddress.tenant.get_absolute_url }}">{{ ipaddress.tenant }}</a>
+                        {% elif ipaddress.vrf.tenant %}
+                            <a href="{{ ipaddress.vrf.tenant.get_absolute_url }}">{{ ipaddress.vrf.tenant }}</a>
+                            <label class="label label-info">Inherited</label>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                     <td>Description</td>
                     <td>
-                        {% if ipaddress.description  %}
+                        {% if ipaddress.description %}
                             <span>{{ ipaddress.description }}</span>
                         {% else %}
-                            <span class="text-muted">None</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>

+ 2 - 1
netbox/templates/ipam/ipaddress_bulk_edit.html

@@ -7,7 +7,8 @@
     {% for ipaddress in selected_objects %}
         <tr>
             <td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td>
-            <td>{{ ipaddress.vrf }}</td>
+            <td>{{ ipaddress.vrf|default:"Global" }}</td>
+            <td>{{ ipaddress.tenant }}</td>
             <td>{{ ipaddress.interface.device }}</td>
             <td>{{ ipaddress.interface }}</td>
             <td>{{ ipaddress.description }}</td>

+ 1 - 0
netbox/templates/ipam/ipaddress_edit.html

@@ -8,6 +8,7 @@
         <div class="panel-body">
             {% render_field form.address %}
             {% render_field form.vrf %}
+            {% render_field form.tenant %}
             {% if obj %}
                 <div class="form-group">
                     <label class="col-md-3 control-label">Device</label>

+ 6 - 1
netbox/templates/ipam/ipaddress_import.html

@@ -38,6 +38,11 @@
 					<td>VRF route distinguisher (optional)</td>
 					<td>65000:123</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>ABC01</td>
+				</tr>
 				<tr>
 					<td>Device</td>
 					<td>Device name (optional)</td>
@@ -61,7 +66,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>192.0.2.42/24,65000:123,switch12,ge-0/0/31,True,Management IP</pre>
+		<pre>192.0.2.42/24,65000:123,ABC01,switch12,ge-0/0/31,True,Management IP</pre>
 	</div>
 </div>
 {% endblock %}

+ 3 - 20
netbox/templates/ipam/ipaddress_list.html

@@ -8,11 +8,11 @@
 <div class="pull-right">
     {% if perms.ipam.add_ipaddress %}
 		<a href="{% url 'ipam:ipaddress_add' %}" class="btn btn-primary">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add an IP
 		</a>
 		<a href="{% url 'ipam:ipaddress_import' %}" class="btn btn-info">
-			<span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+			<span class="fa fa-download" aria-hidden="true"></span>
 			Import IPs
 		</a>
 	{% endif %}
@@ -24,24 +24,7 @@
         {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
 	</div>
 	<div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<span class="glyphicon glyphicon-search" aria-hidden="true"></span> 
-				<strong>Search</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'ipam:ipaddress_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="q" class="form-control" placeholder="IP address" {%  if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>

+ 25 - 6
netbox/templates/ipam/prefix.html

@@ -26,6 +26,19 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if prefix.tenant %}
+                            <a href="{{ prefix.tenant.get_absolute_url }}">{{ prefix.tenant }}</a>
+                        {% elif prefix.vrf.tenant %}
+                            <a href="{{ prefix.vrf.tenant.get_absolute_url }}">{{ prefix.vrf.tenant }}</a>
+                            <label class="label label-info">Inherited</label>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                     <td>Aggregate</td>
                     <td>
@@ -42,7 +55,7 @@
                         {% if prefix.site %}
                             <a href="{% url 'dcim:site' slug=prefix.site.slug %}">{{ prefix.site }}</a>
                         {% else %}
-                            <span class="text-muted">Not assigned</span>
+                            <span class="text-muted">None</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -52,7 +65,7 @@
                         {% if prefix.vlan %}
                             <a href="{% url 'ipam:vlan' pk=prefix.vlan.pk %}">{{ prefix.vlan.display_name }}</a>
                         {% else %}
-                            <span class="text-muted">Not assigned</span>
+                            <span class="text-muted">None</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -64,15 +77,21 @@
                 </tr>
                 <tr>
                     <td>Role</td>
-                    <td>{{ prefix.role }}</td>
+                    <td>
+                        {% if prefix.role %}
+                            <span>{{ prefix.role }}</span>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Description</td>
                     <td>
-                        {% if prefix.description  %}
+                        {% if prefix.description %}
                             <span>{{ prefix.description }}</span>
                         {% else %}
-                            <span class="text-muted">None</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
@@ -108,7 +127,7 @@
             {% include 'utilities/obj_table.html' with table=child_prefix_table table_template='panel_table.html' heading='Child Prefixes' parent=prefix bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
         {% elif prefix.new_subnet %}
             <a href="{% url 'ipam:prefix_add' %}?prefix={{ prefix.new_subnet }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.site %}&site={{ prefix.site.pk }}{% endif %}" class="btn btn-success">
-                <i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add Child Prefix
+                <i class="fa fa-plus" aria-hidden="true"></i> Add Child Prefix
             </a>
         {% endif %}
     </div>

+ 1 - 0
netbox/templates/ipam/prefix_bulk_edit.html

@@ -8,6 +8,7 @@
         <tr>
             <td><a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a></td>
             <td>{{ prefix.vrf|default:"Global" }}</td>
+            <td>{{ prefix.tenant }}</td>
             <td>{{ prefix.site }}</td>
             <td>{{ prefix.status }}</td>
             <td>{{ prefix.role }}</td>

+ 6 - 1
netbox/templates/ipam/prefix_import.html

@@ -38,6 +38,11 @@
 					<td>VRF route distinguisher (optional)</td>
 					<td>65000:123</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>ABC01</td>
+				</tr>
 				<tr>
 					<td>Site</td>
 					<td>Name of assigned site (optional)</td>
@@ -71,7 +76,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>192.168.42.0/24,65000:123,HQ,Customers,801,Active,Customer,7th floor WiFi</pre>
+		<pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,7th floor WiFi</pre>
 	</div>
 </div>
 {% endblock %}

+ 3 - 20
netbox/templates/ipam/prefix_list.html

@@ -8,11 +8,11 @@
 <div class="pull-right">
     {% if perms.ipam.add_prefix %}
 		<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add a prefix
 		</a>
 		<a href="{% url 'ipam:prefix_import' %}" class="btn btn-info">
-			<span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+			<span class="fa fa-download" aria-hidden="true"></span>
 			Import prefixes
 		</a>
 	{% endif %}
@@ -24,24 +24,7 @@
         {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
 	</div>
 	<div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<span class="glyphicon glyphicon-search" aria-hidden="true"></span> 
-				<strong>Search</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'ipam:prefix_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="q" class="form-control" placeholder="Network" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>

+ 1 - 1
netbox/templates/ipam/rir_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.ipam.add_rir %}
         <a href="{% url 'ipam:rir_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a RIR
         </a>
     {% endif %}

+ 1 - 1
netbox/templates/ipam/role_list.html

@@ -7,7 +7,7 @@
 <div class="pull-right">
     {% if perms.dcim.add_devicerole %}
         <a href="{% url 'ipam:role_add' %}" class="btn btn-primary">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            <span class="fa fa-plus" aria-hidden="true"></span>
             Add a role
         </a>
     {% endif %}

+ 30 - 30
netbox/templates/ipam/vlan.html

@@ -7,18 +7,21 @@
 <div class="row">
     <div class="col-md-9">
         <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></li>
-            <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">VLANs</a></li>
+            <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
+            <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
+            {% if vlan.group %}
+                <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}&group={{ vlan.group.slug }}">{{ vlan.group.name }}</a></li>
+            {% endif %}
             <li>{{ vlan.name }} ({{ vlan.vid }})</li>
         </ol>
     </div>
     <div class="col-md-3">
     <form action="{% url 'ipam:vlan_list' %}" method="get">
         <div class="input-group">
-            <input type="text" name="vid" class="form-control" placeholder="VLAN ID search" />
+            <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
             <span class="input-group-btn">
                 <button type="submit" class="btn btn-primary">
-                    <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                    <span class="fa fa-search" aria-hidden="true"></span>
                 </button>
             </span>
         </div>
@@ -28,13 +31,13 @@
 <div class="pull-right">
     {% if perms.ipam.change_vlan %}
         <a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
-            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+            <span class="fa fa-pencil" aria-hidden="true"></span>
             Edit this VLAN
         </a>
     {% endif %}
     {% if perms.ipam.delete_vlan %}
         <a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
-            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+            <span class="fa fa-trash" aria-hidden="true"></span>
             Delete this VLAN
         </a>
     {% endif %}
@@ -70,10 +73,10 @@
                     <td>{{ vlan.name }}</td>
                 </tr>
                 <tr>
-                    <td>Description</td>
+                    <td>Tenant</td>
                     <td>
-                        {% if vlan.description %}
-                            {{ vlan.description }}
+                        {% if vlan.tenant %}
+                            <a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
                         {% endif %}
@@ -87,7 +90,23 @@
                 </tr>
                 <tr>
                     <td>Role</td>
-                    <td>{{ vlan.role }}</td>
+                    <td>
+                        {% if vlan.role %}
+                            <span>{{ vlan.role }}</span>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>
+                        {% if vlan.description %}
+                            {{ vlan.description }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
                 </tr>
                 <tr>
                     <td>Created</td>
@@ -105,26 +124,7 @@
             <div class="panel-heading">
                 <strong>Prefixes</strong>
             </div>
-            {% if prefixes %}
-                <table class="table table-hover panel-body">
-                    {% for p in prefixes %}
-                        <tr>
-                            <td>
-                                <a href="{% url 'ipam:prefix' pk=p.pk %}">{{ p }}</a>
-                            </td>
-                            <td>
-                                {% if p.site %}
-                                    <a href="{% url 'dcim:site' slug=p.site.slug %}">{{ p.site }}</a>
-                                {% endif %}
-                            </td>
-                            <td>{{ p.get_status_display }}</td>
-                            <td>{{ p.role }}</td>
-                        </tr>
-                    {% endfor %}
-                </table>
-            {% else %}
-                <div class="panel-body text-muted">None</div>
-            {% endif %}
+            {% render_table prefix_table %}
         </div>
 	</div>
 </div>

+ 2 - 1
netbox/templates/ipam/vlan_bulk_edit.html

@@ -9,7 +9,8 @@
             <td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan.vid }}</a></td>
             <td>{{ vlan.name }}</td>
             <td>{{ vlan.site }}</td>
-            <td>{{ vlan.status }}</td>
+            <td>{{ vlan.tenant }}</td>
+            <td>{{ vlan.get_status_display }}</td>
             <td>{{ vlan.role }}</td>
             <td>{{ vlan.description }}</td>
         </tr>

+ 6 - 1
netbox/templates/ipam/vlan_import.html

@@ -48,6 +48,11 @@
 					<td>Configured VLAN name</td>
 					<td>Cameras</td>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Internal</td>
+				</tr>
 				<tr>
 					<td>Status</td>
 					<td>Current status</td>
@@ -66,7 +71,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>LAS2,Backend Network,1400,Cameras,Active,Security,Security team only</pre>
+		<pre>LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only</pre>
 	</div>
 </div>
 {% endblock %}

+ 3 - 20
netbox/templates/ipam/vlan_list.html

@@ -8,11 +8,11 @@
 <div class="pull-right">
     {% if perms.ipam.add_vlan %}
 		<a href="{% url 'ipam:vlan_add' %}" class="btn btn-primary">
-			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add a VLAN
 		</a>
 		<a href="{% url 'ipam:vlan_import' %}" class="btn btn-info">
-			<span class="glyphicon glyphicon-import" aria-hidden="true"></span>
+			<span class="fa fa-download" aria-hidden="true"></span>
 			Import VLANs
 		</a>
 	{% endif %}
@@ -24,24 +24,7 @@
         {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:vlan_bulk_edit' bulk_delete_url='ipam:vlan_bulk_delete' %}
 	</div>
 	<div class="col-md-3">
-		<div class="panel panel-default">
-			<div class="panel-heading">
-				<span class="glyphicon glyphicon-search" aria-hidden="true"></span> 
-				<strong>Search by ID</strong>
-			</div>
-			<div class="panel-body">
-				<form action="{% url 'ipam:vlan_list' %}" method="get">
-					<div class="input-group">
-						<input type="text" name="vid" class="form-control" placeholder="VLAN ID" {%  if request.GET.vid %}value="{{ request.GET.vid }}" {% endif %}/>
-						<span class="input-group-btn">
-							<button type="submit" class="btn btn-primary">
-								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
-							</button>
-						</span>
-					</div>
-				</form>
-			</div>
-		</div>
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>

Некоторые файлы не были показаны из-за большого количества измененных файлов