Jelajahi Sumber

Merge pull request #766 from digitalocean/develop

Release v1.8.0
Jeremy Stretch 9 tahun lalu
induk
melakukan
88dace75a1
100 mengubah file dengan 2062 tambahan dan 1223 penghapusan
  1. 6 0
      docs/data-model/ipam.md
  2. 1 1
      docs/installation/netbox.md
  3. 2 2
      docs/installation/postgresql.md
  4. 2 2
      docs/installation/web-server.md
  5. 2 4
      netbox/circuits/admin.py
  6. 12 5
      netbox/circuits/api/serializers.py
  7. 2 2
      netbox/circuits/api/views.py
  8. 8 9
      netbox/circuits/filters.py
  9. 73 60
      netbox/circuits/forms.py
  10. 99 0
      netbox/circuits/migrations/0006_terminations.py
  11. 72 37
      netbox/circuits/models.py
  12. 5 4
      netbox/circuits/tables.py
  13. 6 0
      netbox/circuits/urls.py
  14. 81 5
      netbox/circuits/views.py
  15. 9 7
      netbox/dcim/api/serializers.py
  16. 11 7
      netbox/dcim/api/views.py
  17. 29 20
      netbox/dcim/filters.py
  18. 144 83
      netbox/dcim/forms.py
  19. 20 0
      netbox/dcim/migrations/0023_devicetype_comments.py
  20. 30 0
      netbox/dcim/migrations/0024_site_add_contact_fields.py
  21. 26 11
      netbox/dcim/models.py
  22. 5 0
      netbox/dcim/tests/test_apis.py
  23. 23 16
      netbox/dcim/urls.py
  24. 272 403
      netbox/dcim/views.py
  25. 3 2
      netbox/extras/forms.py
  26. 1 1
      netbox/extras/models.py
  27. 22 3
      netbox/ipam/api/serializers.py
  28. 4 0
      netbox/ipam/api/urls.py
  29. 22 1
      netbox/ipam/api/views.py
  30. 28 11
      netbox/ipam/filters.py
  31. 46 23
      netbox/ipam/forms.py
  32. 39 0
      netbox/ipam/migrations/0012_services.py
  33. 37 0
      netbox/ipam/migrations/0013_prefix_add_is_pool.py
  34. 43 3
      netbox/ipam/models.py
  35. 31 5
      netbox/ipam/tables.py
  36. 5 0
      netbox/ipam/urls.py
  37. 43 17
      netbox/ipam/views.py
  38. 9 2
      netbox/netbox/settings.py
  39. 6 0
      netbox/netbox/urls.py
  40. 12 4
      netbox/project-static/css/base.css
  41. 8 0
      netbox/project-static/js/forms.js
  42. 2 2
      netbox/secrets/filters.py
  43. 6 6
      netbox/secrets/forms.py
  44. 8 75
      netbox/templates/circuits/circuit.html
  45. 0 34
      netbox/templates/circuits/circuit_edit.html
  46. 1 26
      netbox/templates/circuits/circuit_import.html
  47. 25 0
      netbox/templates/circuits/circuit_terminations_swap.html
  48. 94 0
      netbox/templates/circuits/circuittermination_edit.html
  49. 95 0
      netbox/templates/circuits/inc/circuit_termination.html
  50. 12 11
      netbox/templates/circuits/provider.html
  51. 0 13
      netbox/templates/dcim/component_template_delete.html
  52. 47 17
      netbox/templates/dcim/device.html
  53. 4 4
      netbox/templates/dcim/device_component_add.html
  54. 1 1
      netbox/templates/dcim/device_edit.html
  55. 2 2
      netbox/templates/dcim/device_inventory.html
  56. 1 1
      netbox/templates/dcim/device_lldp_neighbors.html
  57. 16 1
      netbox/templates/dcim/devicetype.html
  58. 2 2
      netbox/templates/dcim/devicetype_component_add.html
  59. 34 0
      netbox/templates/dcim/devicetype_edit.html
  60. 1 0
      netbox/templates/dcim/devicetype_list.html
  61. 3 2
      netbox/templates/dcim/inc/_device_header.html
  62. 20 11
      netbox/templates/dcim/inc/_interface.html
  63. 26 0
      netbox/templates/dcim/inc/_service.html
  64. 14 4
      netbox/templates/dcim/inc/device_table.html
  65. 1 1
      netbox/templates/dcim/inc/devicetype_component_table.html
  66. 9 2
      netbox/templates/dcim/interfaceconnection_edit.html
  67. 4 4
      netbox/templates/dcim/rack.html
  68. 34 4
      netbox/templates/dcim/site.html
  69. 8 0
      netbox/templates/dcim/site_edit.html
  70. 16 1
      netbox/templates/dcim/site_import.html
  71. 7 7
      netbox/templates/home.html
  72. 1 1
      netbox/templates/inc/custom_fields_panel.html
  73. 4 4
      netbox/templates/ipam/aggregate.html
  74. 4 0
      netbox/templates/ipam/inc/ipadress_edit_header.html
  75. 3 2
      netbox/templates/ipam/inc/prefix_header.html
  76. 4 4
      netbox/templates/ipam/ipaddress.html
  77. 22 0
      netbox/templates/ipam/ipaddress_bulk_add.html
  78. 7 1
      netbox/templates/ipam/ipaddress_edit.html
  79. 11 2
      netbox/templates/ipam/prefix.html
  80. 1 0
      netbox/templates/ipam/prefix_edit.html
  81. 6 1
      netbox/templates/ipam/prefix_import.html
  82. 26 0
      netbox/templates/ipam/service_edit.html
  83. 4 4
      netbox/templates/ipam/vlan.html
  84. 4 4
      netbox/templates/ipam/vrf.html
  85. 5 6
      netbox/templates/paginator.html
  86. 1 1
      netbox/templates/secrets/secret.html
  87. 4 4
      netbox/templates/tenancy/tenant.html
  88. 22 0
      netbox/templates/users/_user.html
  89. 21 37
      netbox/templates/users/change_password.html
  90. 0 6
      netbox/templates/users/inc/profile_nav.html
  91. 14 26
      netbox/templates/users/profile.html
  92. 16 29
      netbox/templates/users/recent_activity.html
  93. 29 42
      netbox/templates/users/userkey.html
  94. 41 54
      netbox/templates/users/userkey_edit.html
  95. 2 2
      netbox/templates/utilities/confirmation_form.html
  96. 6 6
      netbox/templates/utilities/obj_edit.html
  97. 2 1
      netbox/templates/utilities/obj_table.html
  98. 2 2
      netbox/tenancy/forms.py
  99. 2 2
      netbox/users/forms.py
  100. 6 1
      netbox/users/views.py

+ 6 - 0
docs/data-model/ipam.md

@@ -86,3 +86,9 @@ One IP address can be designated as the network address translation (NAT) IP add
 A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice.
 A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice.
 
 
 Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.
 Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.
+
+---
+
+# Services
+
+A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, SSH (TCP/22). A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any IP address.)

+ 1 - 1
docs/installation/netbox.md

@@ -195,7 +195,7 @@ Starting development server at http://0.0.0.0:8000/
 Quit the server with CONTROL-C.
 Quit the server with CONTROL-C.
 ```
 ```
 
 
-Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use.
+Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
 
 
 !!! warning
 !!! warning
     If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
     If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.

+ 2 - 2
docs/installation/postgresql.md

@@ -1,4 +1,4 @@
-NetBox requires a PostgreSQL database to store data. MySQL is not supported, as NetBox leverage's PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).
+NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)
 
 
 # Installation
 # Installation
 
 
@@ -15,7 +15,7 @@ NetBox requires a PostgreSQL database to store data. MySQL is not supported, as
 # postgresql-setup initdb
 # postgresql-setup initdb
 ```
 ```
 
 
-If using CentOS, modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
+CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
 
 
 ```no-highlight
 ```no-highlight
 host    all             all             127.0.0.1/32            md5
 host    all             all             127.0.0.1/32            md5

+ 2 - 2
docs/installation/web-server.md

@@ -101,7 +101,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https
 
 
 # gunicorn Installation
 # gunicorn Installation
 
 
-Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL change the username from `www-data` to `nginx` or `apache`.
+Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
 
 
 ```no-highlight
 ```no-highlight
 command = '/usr/bin/gunicorn'
 command = '/usr/bin/gunicorn'
@@ -113,7 +113,7 @@ user = 'www-data'
 
 
 # supervisord Installation
 # supervisord Installation
 
 
-Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
+Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
 
 
 ```no-highlight
 ```no-highlight
 [program:netbox]
 [program:netbox]

+ 2 - 4
netbox/circuits/admin.py

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

+ 12 - 5
netbox/circuits/api/serializers.py

@@ -1,6 +1,6 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from circuits.models import Provider, CircuitType, Circuit
+from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from extras.api.serializers import CustomFieldSerializer
 from extras.api.serializers import CustomFieldSerializer
 from tenancy.api.serializers import TenantNestedSerializer
 from tenancy.api.serializers import TenantNestedSerializer
@@ -45,17 +45,24 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
 # Circuits
 # Circuits
 #
 #
 
 
+class CircuitTerminationSerializer(serializers.ModelSerializer):
+    site = SiteNestedSerializer()
+    interface = InterfaceNestedSerializer()
+
+    class Meta:
+        model = CircuitTermination
+        fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info']
+
+
 class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
 class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
     provider = ProviderNestedSerializer()
     provider = ProviderNestedSerializer()
     type = CircuitTypeNestedSerializer()
     type = CircuitTypeNestedSerializer()
     tenant = TenantNestedSerializer()
     tenant = TenantNestedSerializer()
-    site = SiteNestedSerializer()
-    interface = InterfaceNestedSerializer()
+    terminations = CircuitTerminationSerializer(many=True)
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
-                  'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
+        fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields']
 
 
 
 
 class CircuitNestedSerializer(CircuitSerializer):
 class CircuitNestedSerializer(CircuitSerializer):

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

@@ -43,7 +43,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
     """
     """
     List circuits (filterable)
     List circuits (filterable)
     """
     """
-    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
         .prefetch_related('custom_field_values__field')
         .prefetch_related('custom_field_values__field')
     serializer_class = serializers.CircuitSerializer
     serializer_class = serializers.CircuitSerializer
     filter_class = CircuitFilter
     filter_class = CircuitFilter
@@ -53,6 +53,6 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
     """
     """
     Retrieve a single circuit
     Retrieve a single circuit
     """
     """
-    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
         .prefetch_related('custom_field_values__field')
         .prefetch_related('custom_field_values__field')
     serializer_class = serializers.CircuitSerializer
     serializer_class = serializers.CircuitSerializer

+ 8 - 9
netbox/circuits/filters.py

@@ -16,12 +16,12 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='circuits__site',
+        name='circuits__terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site',
         label='Site',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='circuits__site',
+        name='circuits__terminations__site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -29,7 +29,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
-        fields = ['q', 'name', 'account', 'asn']
+        fields = ['name', 'account', 'asn']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         return queryset.filter(
         return queryset.filter(
@@ -50,7 +50,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Provider (ID)',
         label='Provider (ID)',
     )
     )
     provider = django_filters.ModelMultipleChoiceFilter(
     provider = django_filters.ModelMultipleChoiceFilter(
-        name='provider',
+        name='provider__slug',
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Provider (slug)',
         label='Provider (slug)',
@@ -61,7 +61,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Circuit type (ID)',
         label='Circuit type (ID)',
     )
     )
     type = django_filters.ModelMultipleChoiceFilter(
     type = django_filters.ModelMultipleChoiceFilter(
-        name='type',
+        name='type__slug',
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Circuit type (slug)',
         label='Circuit type (slug)',
@@ -78,12 +78,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='terminations__site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -91,12 +91,11 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
+        fields = ['install_date']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         return queryset.filter(
         return queryset.filter(
             Q(cid__icontains=value) |
             Q(cid__icontains=value) |
             Q(xconnect_id__icontains=value) |
             Q(xconnect_id__icontains=value) |
-            Q(pp_info__icontains=value) |
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )

+ 73 - 60
netbox/circuits/forms.py

@@ -9,7 +9,7 @@ from utilities.forms import (
     SlugField,
     SlugField,
 )
 )
 
 
-from .models import Circuit, CircuitType, Provider
+from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
 
 
 #
 #
@@ -43,7 +43,7 @@ class ProviderFromCSVForm(forms.ModelForm):
         fields = ['name', 'slug', 'asn', 'account', 'portal_url']
         fields = ['name', 'slug', 'asn', 'account', 'portal_url']
 
 
 
 
-class ProviderImportForm(BulkImportForm, BootstrapMixin):
+class ProviderImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=ProviderFromCSVForm)
     csv = CSVDataField(csv_form=ProviderFromCSVForm)
 
 
 
 
@@ -69,7 +69,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Circuit types
 # Circuit types
 #
 #
 
 
-class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
+class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -82,6 +82,64 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
 #
 #
 
 
 class CircuitForm(BootstrapMixin, CustomFieldForm):
 class CircuitForm(BootstrapMixin, CustomFieldForm):
+    comments = CommentField()
+
+    class Meta:
+        model = Circuit
+        fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments']
+        help_texts = {
+            'cid': "Unique circuit ID",
+            'install_date': "Format: YYYY-MM-DD",
+            'commit_rate': "Committed rate",
+        }
+
+
+class CircuitFromCSVForm(forms.ModelForm):
+    provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
+                                      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.'})
+
+    class Meta:
+        model = Circuit
+        fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate']
+
+
+class CircuitImportForm(BootstrapMixin, BulkImportForm):
+    csv = CSVDataField(csv_form=CircuitFromCSVForm)
+
+
+class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    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.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
+    comments = CommentField(widget=SmallTextarea)
+
+    class Meta:
+        nullable_fields = ['tenant', 'commit_rate', 'comments']
+
+
+class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = Circuit
+    type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
+                             to_field_name='slug')
+    provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
+                                 to_field_name='slug')
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
+                               null_option=(0, 'None'))
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
+                             to_field_name='slug')
+
+
+#
+# Circuit terminations
+#
+
+class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
                                   widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
                                   widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
@@ -95,28 +153,25 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
     interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
     interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
                                        widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
                                        widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
                                                         disabled_indicator='is_connected'))
                                                         disabled_indicator='is_connected'))
-    comments = CommentField()
 
 
     class Meta:
     class Meta:
-        model = Circuit
-        fields = [
-            'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
-            'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
-        ]
+        model = CircuitTermination
+        fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
+                  'xconnect_id', 'pp_info']
         help_texts = {
         help_texts = {
-            'cid': "Unique circuit ID",
-            'install_date': "Format: YYYY-MM-DD",
             'port_speed': "Physical circuit speed",
             'port_speed': "Physical circuit speed",
-            'commit_rate': "Commited rate",
             'xconnect_id': "ID of the local cross-connect",
             'xconnect_id': "ID of the local cross-connect",
             'pp_info': "Patch panel ID and port number(s)"
             'pp_info': "Patch panel ID and port number(s)"
         }
         }
+        widgets = {
+            'term_side': forms.HiddenInput(),
+        }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
 
 
-        super(CircuitForm, self).__init__(*args, **kwargs)
+        super(CircuitTerminationForm, self).__init__(*args, **kwargs)
 
 
-        # If this circuit has been assigned to an interface, initialize rack and device
+        # If an interface has been assigned, initialize rack and device
         if self.instance.interface:
         if self.instance.interface:
             self.initial['rack'] = self.instance.interface.device.rack
             self.initial['rack'] = self.instance.interface.device.rack
             self.initial['device'] = self.instance.interface.device
             self.initial['device'] = self.instance.interface.device
@@ -140,11 +195,13 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
         # Limit interface choices
         # Limit interface choices
         if self.is_bound and self.data.get('device'):
         if self.is_bound and self.data.get('device'):
             interfaces = Interface.objects.filter(device=self.data['device'])\
             interfaces = Interface.objects.filter(device=self.data['device'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
+                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
+                                                                      'connected_as_b')
             self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
             self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
         elif self.initial.get('device'):
         elif self.initial.get('device'):
             interfaces = Interface.objects.filter(device=self.initial['device'])\
             interfaces = Interface.objects.filter(device=self.initial['device'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
+                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
+                                                                      'connected_as_b')
             self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
             self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
         else:
         else:
             interfaces = []
             interfaces = []
@@ -154,47 +211,3 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
                 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
                 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
             }) for iface in interfaces
             }) for iface in interfaces
         ]
         ]
-
-
-class CircuitFromCSVForm(forms.ModelForm):
-    provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
-                                      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', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
-                  'commit_rate', 'xconnect_id', 'pp_info']
-
-
-class CircuitImportForm(BulkImportForm, BootstrapMixin):
-    csv = CSVDataField(csv_form=CircuitFromCSVForm)
-
-
-class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
-    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.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
-    port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
-    commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
-    comments = CommentField(widget=SmallTextarea)
-
-    class Meta:
-        nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']
-
-
-class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
-    model = Circuit
-    type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
-                             to_field_name='slug')
-    provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
-                                 to_field_name='slug')
-    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
-                               null_option=(0, 'None'))
-    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug')

+ 99 - 0
netbox/circuits/migrations/0006_terminations.py

@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-13 16:30
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def circuits_to_terms(apps, schema_editor):
+    Circuit = apps.get_model('circuits', 'Circuit')
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    for c in Circuit.objects.all():
+        CircuitTermination(
+            circuit=c,
+            term_side=b'A',
+            site=c.site,
+            interface=c.interface,
+            port_speed=c.port_speed,
+            upstream_speed=c.upstream_speed,
+            xconnect_id=c.xconnect_id,
+            pp_info=c.pp_info,
+        ).save()
+
+
+def terms_to_circuits(apps, schema_editor):
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    for ct in CircuitTermination.objects.filter(term_side='A'):
+        c = ct.circuit
+        c.site = ct.site
+        c.interface = ct.interface
+        c.port_speed = ct.port_speed
+        c.upstream_speed = ct.upstream_speed
+        c.xconnect_id = ct.xconnect_id
+        c.pp_info = ct.pp_info
+        c.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0022_color_names_to_rgb'),
+        ('circuits', '0005_circuit_add_upstream_speed'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CircuitTermination',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('term_side', models.CharField(choices=[(b'A', b'A'), (b'Z', b'Z')], max_length=1,
+                                               verbose_name='Termination')),
+                ('port_speed', models.PositiveIntegerField(verbose_name=b'Port speed (Kbps)')),
+                ('upstream_speed',
+                 models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed',
+                                             null=True, verbose_name=b'Upstream speed (Kbps)')),
+                ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')),
+                ('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')),
+                ('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations',
+                                              to='circuits.Circuit')),
+                ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                                   related_name='circuit_termination', to='dcim.Interface')),
+                ('site',
+                 models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations',
+                                   to='dcim.Site')),
+            ],
+            options={
+                'ordering': ['circuit', 'term_side'],
+            },
+        ),
+        migrations.AlterUniqueTogether(
+            name='circuittermination',
+            unique_together=set([('circuit', 'term_side')]),
+        ),
+        migrations.RunPython(circuits_to_terms, terms_to_circuits),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='interface',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='port_speed',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='pp_info',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='site',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='upstream_speed',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='xconnect_id',
+        ),
+    ]

+ 72 - 37
netbox/circuits/models.py

@@ -3,12 +3,35 @@ from django.core.urlresolvers import reverse
 from django.db import models
 from django.db import models
 
 
 from dcim.fields import ASNField
 from dcim.fields import ASNField
-from dcim.models import Site, Interface
 from extras.models import CustomFieldModel, CustomFieldValue
 from extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 
 
 
 
+TERM_SIDE_A = 'A'
+TERM_SIDE_Z = 'Z'
+TERM_SIDE_CHOICES = (
+    (TERM_SIDE_A, 'A'),
+    (TERM_SIDE_Z, 'Z'),
+)
+
+
+def humanize_speed(speed):
+    """
+    Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
+    """
+    if speed >= 1000000000 and speed % 1000000000 == 0:
+        return '{} Tbps'.format(speed / 1000000000)
+    elif speed >= 1000000 and speed % 1000000 == 0:
+        return '{} Gbps'.format(speed / 1000000)
+    elif speed >= 1000 and speed % 1000 == 0:
+        return '{} Mbps'.format(speed / 1000)
+    elif speed >= 1000:
+        return '{} Mbps'.format(float(speed) / 1000)
+    else:
+        return '{} Kbps'.format(speed)
+
+
 class Provider(CreatedUpdatedModel, CustomFieldModel):
 class Provider(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -71,15 +94,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     type = models.ForeignKey('CircuitType', 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)
     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')
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
-    port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
-    upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
-                                                 help_text='Upstream speed, if different from port speed')
     commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
     commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
-    xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
-    pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
 
@@ -99,42 +115,61 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
             self.provider.name,
             self.provider.name,
             self.type.name,
             self.type.name,
             self.tenant.name if self.tenant else '',
             self.tenant.name if self.tenant else '',
-            self.site.name,
             self.install_date.isoformat() if self.install_date else '',
             self.install_date.isoformat() if self.install_date else '',
-            str(self.port_speed),
-            str(self.upstream_speed),
             str(self.commit_rate) if self.commit_rate else '',
             str(self.commit_rate) if self.commit_rate else '',
-            self.xconnect_id,
-            self.pp_info,
         ])
         ])
 
 
-    def _humanize_speed(self, speed):
-        """
-        Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
-        """
-        if speed >= 1000000000 and speed % 1000000000 == 0:
-            return '{} Tbps'.format(speed / 1000000000)
-        elif speed >= 1000000 and speed % 1000000 == 0:
-            return '{} Gbps'.format(speed / 1000000)
-        elif speed >= 1000 and speed % 1000 == 0:
-            return '{} Mbps'.format(speed / 1000)
-        elif speed >= 1000:
-            return '{} Mbps'.format(float(speed) / 1000)
-        else:
-            return '{} Kbps'.format(speed)
+    def _get_termination(self, side):
+        for ct in self.terminations.all():
+            if ct.term_side == side:
+                return ct
+        return None
+
+    @property
+    def termination_a(self):
+        return self._get_termination('A')
+
+    @property
+    def termination_z(self):
+        return self._get_termination('Z')
+
+    def commit_rate_human(self):
+        return '' if not self.commit_rate else humanize_speed(self.commit_rate)
+    commit_rate_human.admin_order_field = 'commit_rate'
+
+
+class CircuitTermination(models.Model):
+    circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
+    term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
+    site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
+    interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
+    port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
+    upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
+                                                 help_text='Upstream speed, if different from port speed')
+    xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
+    pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
+
+    class Meta:
+        ordering = ['circuit', 'term_side']
+        unique_together = ['circuit', 'term_side']
+
+    def __unicode__(self):
+        return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
+
+    def get_parent_url(self):
+        return self.circuit.get_absolute_url()
+
+    def get_peer_termination(self):
+        peer_side = 'Z' if self.term_side == 'A' else 'A'
+        try:
+            return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side)
+        except CircuitTermination.DoesNotExist:
+            return None
 
 
     def port_speed_human(self):
     def port_speed_human(self):
-        return self._humanize_speed(self.port_speed)
+        return humanize_speed(self.port_speed)
     port_speed_human.admin_order_field = 'port_speed'
     port_speed_human.admin_order_field = 'port_speed'
 
 
     def upstream_speed_human(self):
     def upstream_speed_human(self):
-        if not self.upstream_speed:
-            return ''
-        return self._humanize_speed(self.upstream_speed)
+        return '' if not self.upstream_speed else humanize_speed(self.upstream_speed)
     upstream_speed_human.admin_order_field = 'upstream_speed'
     upstream_speed_human.admin_order_field = 'upstream_speed'
-
-    def commit_rate_human(self):
-        if not self.commit_rate:
-            return ''
-        return self._humanize_speed(self.commit_rate)
-    commit_rate_human.admin_order_field = 'commit_rate'

+ 5 - 4
netbox/circuits/tables.py

@@ -56,12 +56,13 @@ class CircuitTable(BaseTable):
     type = tables.Column(verbose_name='Type')
     type = tables.Column(verbose_name='Type')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     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 = tables.Column(accessor=Accessor('port_speed_human'), order_by=Accessor('port_speed'),
-                               verbose_name='Port Speed')
+    a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
+                               args=[Accessor('termination_a.site.slug')])
+    z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
+                               args=[Accessor('termination_z.site.slug')])
     commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
     commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
                                 verbose_name='Commit Rate')
                                 verbose_name='Commit Rate')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Circuit
         model = Circuit
-        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed', 'commit_rate')
+        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'commit_rate')

+ 6 - 0
netbox/circuits/urls.py

@@ -30,5 +30,11 @@ urlpatterns = [
     url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
     url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
     url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
     url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
     url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
     url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
+    url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
+
+    # Circuit terminations
+    url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
+    url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
+    url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
 
 
 ]
 ]

+ 81 - 5
netbox/circuits/views.py

@@ -1,14 +1,18 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db import transaction
 from django.db.models import Count
 from django.db.models import Count
-from django.shortcuts import get_object_or_404, render
+from django.shortcuts import get_object_or_404, redirect, render
 
 
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
+from utilities.forms import ConfirmationForm
 from utilities.views import (
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
 
 
 from . import filters, forms, tables
 from . import filters, forms, tables
-from .models import Circuit, CircuitType, Provider
+from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
 
 
 
 
 #
 #
@@ -27,7 +31,7 @@ class ProviderListView(ObjectListView):
 def provider(request, slug):
 def provider(request, slug):
 
 
     provider = get_object_or_404(Provider, slug=slug)
     provider = get_object_or_404(Provider, slug=slug)
-    circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
+    circuits = Circuit.objects.filter(provider=provider)
     show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
     show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
 
 
     return render(request, 'circuits/provider.html', {
     return render(request, 'circuits/provider.html', {
@@ -103,7 +107,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class CircuitListView(ObjectListView):
 class CircuitListView(ObjectListView):
-    queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
+    queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable
     table = tables.CircuitTable
@@ -114,9 +118,13 @@ class CircuitListView(ObjectListView):
 def circuit(request, pk):
 def circuit(request, pk):
 
 
     circuit = get_object_or_404(Circuit, pk=pk)
     circuit = get_object_or_404(Circuit, pk=pk)
+    termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
+    termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
 
 
     return render(request, 'circuits/circuit.html', {
     return render(request, 'circuits/circuit.html', {
         'circuit': circuit,
         'circuit': circuit,
+        'termination_a': termination_a,
+        'termination_z': termination_z,
     })
     })
 
 
 
 
@@ -124,7 +132,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'circuits.change_circuit'
     permission_required = 'circuits.change_circuit'
     model = Circuit
     model = Circuit
     form_class = forms.CircuitForm
     form_class = forms.CircuitForm
-    fields_initial = ['site']
+    fields_initial = ['provider']
     template_name = 'circuits/circuit_edit.html'
     template_name = 'circuits/circuit_edit.html'
     obj_list_url = 'circuits:circuit_list'
     obj_list_url = 'circuits:circuit_list'
 
 
@@ -155,3 +163,71 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuit'
     permission_required = 'circuits.delete_circuit'
     cls = Circuit
     cls = Circuit
     default_redirect_url = 'circuits:circuit_list'
     default_redirect_url = 'circuits:circuit_list'
+
+
+@permission_required('circuits.change_circuittermination')
+def circuit_terminations_swap(request, pk):
+
+    circuit = get_object_or_404(Circuit, pk=pk)
+    termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
+    termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
+    if not termination_a and not termination_z:
+        messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
+        return redirect('circuits:circuit', pk=circuit.pk)
+
+    if request.method == 'POST':
+        form = ConfirmationForm(request.POST)
+        if form.is_valid():
+            if termination_a and termination_z:
+                # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
+                with transaction.atomic():
+                    termination_a.term_side = '_'
+                    termination_a.save()
+                    termination_z.term_side = 'A'
+                    termination_z.save()
+                    termination_a.term_side = 'Z'
+                    termination_a.save()
+            elif termination_a:
+                termination_a.term_side = 'Z'
+                termination_a.save()
+            else:
+                termination_z.term_side = 'A'
+                termination_z.save()
+            messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
+            return redirect('circuits:circuit', pk=circuit.pk)
+
+    else:
+        form = ConfirmationForm()
+
+    return render(request, 'circuits/circuit_terminations_swap.html', {
+        'circuit': circuit,
+        'termination_a': termination_a,
+        'termination_z': termination_z,
+        'form': form,
+        'panel_class': 'default',
+        'button_class': 'primary',
+        'cancel_url': circuit.get_absolute_url(),
+    })
+
+
+#
+# Circuit terminations
+#
+
+class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'circuits.change_circuittermination'
+    model = CircuitTermination
+    form_class = forms.CircuitTerminationForm
+    fields_initial = ['term_side']
+    template_name = 'circuits/circuittermination_edit.html'
+
+    def alter_obj(self, obj, args, kwargs):
+        if 'circuit' in kwargs:
+            circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
+            obj.circuit = circuit
+        return obj
+
+
+class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'circuits.delete_circuittermination'
+    model = CircuitTermination

+ 9 - 7
netbox/dcim/api/serializers.py

@@ -20,8 +20,9 @@ class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
-                  'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
+        fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
+                  'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
+                  'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
 
 
 
 
 class SiteNestedSerializer(SiteSerializer):
 class SiteNestedSerializer(SiteSerializer):
@@ -130,14 +131,14 @@ class ManufacturerNestedSerializer(ManufacturerSerializer):
 # Device types
 # Device types
 #
 #
 
 
-class DeviceTypeSerializer(serializers.ModelSerializer):
+class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
     manufacturer = ManufacturerNestedSerializer()
     manufacturer = ManufacturerNestedSerializer()
     subdevice_role = serializers.SerializerMethodField()
     subdevice_role = serializers.SerializerMethodField()
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-                  'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role']
+                  'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields']
 
 
     def get_subdevice_role(self, obj):
     def get_subdevice_role(self, obj):
         return {
         return {
@@ -197,8 +198,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer):
 
 
     class Meta(DeviceTypeSerializer.Meta):
     class Meta(DeviceTypeSerializer.Meta):
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-                  'is_console_server', 'is_pdu', 'is_network_device', 'console_port_templates', 'cs_port_templates',
-                  'power_port_templates', 'power_outlet_templates', 'interface_templates']
+                  'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
+                  'console_port_templates', 'cs_port_templates', 'power_port_templates', 'power_outlet_templates',
+                  'interface_templates']
 
 
 
 
 #
 #
@@ -381,7 +383,7 @@ class InterfaceNestedSerializer(InterfaceSerializer):
 
 
 
 
 class InterfaceDetailSerializer(InterfaceSerializer):
 class InterfaceDetailSerializer(InterfaceSerializer):
-    connected_interface = InterfaceSerializer(source='get_connected_interface')
+    connected_interface = InterfaceSerializer()
 
 
     class Meta(InterfaceSerializer.Meta):
     class Meta(InterfaceSerializer.Meta):
         fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',
         fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',

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

@@ -118,7 +118,11 @@ class RackUnitListView(APIView):
 
 
         rack = get_object_or_404(Rack, pk=pk)
         rack = get_object_or_404(Rack, pk=pk)
         face = request.GET.get('face', 0)
         face = request.GET.get('face', 0)
-        elevation = rack.get_rack_units(face)
+        try:
+            exclude = int(request.GET.get('exclude', None))
+        except ValueError:
+            exclude = None
+        elevation = rack.get_rack_units(face, exclude)
 
 
         # Serialize Devices within the rack elevation
         # Serialize Devices within the rack elevation
         for u in elevation:
         for u in elevation:
@@ -152,20 +156,20 @@ class ManufacturerDetailView(generics.RetrieveAPIView):
 # Device Types
 # Device Types
 #
 #
 
 
-class DeviceTypeListView(generics.ListAPIView):
+class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView):
     """
     """
     List device types (filterable)
     List device types (filterable)
     """
     """
-    queryset = DeviceType.objects.select_related('manufacturer')
+    queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
     serializer_class = serializers.DeviceTypeSerializer
     serializer_class = serializers.DeviceTypeSerializer
     filter_class = filters.DeviceTypeFilter
     filter_class = filters.DeviceTypeFilter
 
 
 
 
-class DeviceTypeDetailView(generics.RetrieveAPIView):
+class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
     """
     """
     Retrieve a single device type
     Retrieve a single device type
     """
     """
-    queryset = DeviceType.objects.select_related('manufacturer')
+    queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
     serializer_class = serializers.DeviceTypeDetailSerializer
     serializer_class = serializers.DeviceTypeDetailSerializer
 
 
 
 
@@ -451,7 +455,7 @@ class RelatedConnectionsView(APIView):
                 peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
                 peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
             except Interface.DoesNotExist:
             except Interface.DoesNotExist:
                 raise Http404()
                 raise Http404()
-            local_iface = peer_iface.get_connected_interface()
+            local_iface = peer_iface.connected_interface
             if local_iface:
             if local_iface:
                 device = local_iface.device
                 device = local_iface.device
             else:
             else:
@@ -484,7 +488,7 @@ class RelatedConnectionsView(APIView):
 
 
         # Interface connections
         # Interface connections
         interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
         interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
-                                                                            'circuit')
+                                                                            'circuit_termination')
         for iface in interfaces:
         for iface in interfaces:
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             del(data['device'])
             del(data['device'])

+ 29 - 20
netbox/dcim/filters.py

@@ -50,7 +50,7 @@ class RackGroupFilter(django_filters.FilterSet):
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -58,7 +58,6 @@ class RackGroupFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
-        fields = ['site_id', 'site']
 
 
 
 
 class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -72,7 +71,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -113,7 +112,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = ['q', 'site_id', 'site', 'u_height']
+        fields = ['u_height']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         return queryset.filter(
         return queryset.filter(
@@ -123,14 +122,18 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         )
         )
 
 
 
 
-class DeviceTypeFilter(django_filters.FilterSet):
+class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
+    q = django_filters.MethodFilter(
+        action='search',
+        label='Search',
+    )
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         name='manufacturer',
         name='manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         label='Manufacturer (ID)',
         label='Manufacturer (ID)',
     )
     )
     manufacturer = django_filters.ModelMultipleChoiceFilter(
     manufacturer = django_filters.ModelMultipleChoiceFilter(
-        name='manufacturer',
+        name='manufacturer__slug',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (slug)',
         label='Manufacturer (slug)',
@@ -138,8 +141,16 @@ class DeviceTypeFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
-        fields = ['manufacturer_id', 'manufacturer', 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu',
-                  'is_network_device']
+        fields = ['model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device',
+                  'subdevice_role']
+
+    def search(self, queryset, value):
+        return queryset.filter(
+            Q(manufacturer__name__icontains=value) |
+            Q(model__icontains=value) |
+            Q(part_number__icontains=value) |
+            Q(comments__icontains=value)
+        )
 
 
 
 
 class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -157,7 +168,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='rack__site',
+        name='rack__site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site name (slug)',
         label='Site name (slug)',
@@ -178,7 +189,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Role (ID)',
         label='Role (ID)',
     )
     )
     role = django_filters.ModelMultipleChoiceFilter(
     role = django_filters.ModelMultipleChoiceFilter(
-        name='device_role',
+        name='device_role__slug',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
@@ -205,13 +216,13 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Manufacturer (ID)',
         label='Manufacturer (ID)',
     )
     )
     manufacturer = django_filters.ModelMultipleChoiceFilter(
     manufacturer = django_filters.ModelMultipleChoiceFilter(
-        name='device_type__manufacturer',
+        name='device_type__manufacturer__slug',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (slug)',
         label='Manufacturer (slug)',
     )
     )
     model = django_filters.ModelMultipleChoiceFilter(
     model = django_filters.ModelMultipleChoiceFilter(
-        name='device_type',
+        name='device_type__slug',
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Device model (slug)',
         label='Device model (slug)',
@@ -246,9 +257,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
-        fields = ['q', 'name', 'serial', 'asset_tag', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id',
-                  'manufacturer_id', 'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server',
-                  'is_pdu', 'is_network_device']
+        fields = ['name', 'serial', 'asset_tag']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         return queryset.filter(
         return queryset.filter(
@@ -284,7 +293,7 @@ class ConsolePortFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
-        fields = ['device_id', 'device', 'name']
+        fields = ['name']
 
 
 
 
 class ConsoleServerPortFilter(django_filters.FilterSet):
 class ConsoleServerPortFilter(django_filters.FilterSet):
@@ -302,7 +311,7 @@ class ConsoleServerPortFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ['device_id', 'device', 'name']
+        fields = ['name']
 
 
 
 
 class PowerPortFilter(django_filters.FilterSet):
 class PowerPortFilter(django_filters.FilterSet):
@@ -320,7 +329,7 @@ class PowerPortFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
-        fields = ['device_id', 'device', 'name']
+        fields = ['name']
 
 
 
 
 class PowerOutletFilter(django_filters.FilterSet):
 class PowerOutletFilter(django_filters.FilterSet):
@@ -338,7 +347,7 @@ class PowerOutletFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
-        fields = ['device_id', 'device', 'name']
+        fields = ['name']
 
 
 
 
 class InterfaceFilter(django_filters.FilterSet):
 class InterfaceFilter(django_filters.FilterSet):
@@ -356,7 +365,7 @@ class InterfaceFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['device_id', 'device', 'name']
+        fields = ['name']
 
 
 
 
 class ConsoleConnectionFilter(django_filters.FilterSet):
 class ConsoleConnectionFilter(django_filters.FilterSet):

+ 144 - 83
netbox/dcim/forms.py

@@ -13,6 +13,7 @@ from utilities.forms import (
     SlugField,
     SlugField,
 )
 )
 
 
+from formfields import MACAddressFormField
 from .models import (
 from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
@@ -61,7 +62,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
+                  'contact_phone', 'contact_email', 'comments']
         widgets = {
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
@@ -81,19 +83,20 @@ class SiteFromCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['name', 'slug', 'tenant', 'facility', 'asn']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
 
 
 
 
-class SiteImportForm(BulkImportForm, BootstrapMixin):
+class SiteImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=SiteFromCSVForm)
     csv = CSVDataField(csv_form=SiteFromCSVForm)
 
 
 
 
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['tenant']
+        nullable_fields = ['tenant', 'asn']
 
 
 
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -106,7 +109,7 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Rack groups
 # Rack groups
 #
 #
 
 
-class RackGroupForm(forms.ModelForm, BootstrapMixin):
+class RackGroupForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -114,7 +117,7 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
         fields = ['site', 'name', 'slug']
         fields = ['site', 'name', 'slug']
 
 
 
 
-class RackGroupFilterForm(forms.Form, BootstrapMixin):
+class RackGroupFilterForm(BootstrapMixin, forms.Form):
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
 
 
 
 
@@ -122,7 +125,7 @@ class RackGroupFilterForm(forms.Form, BootstrapMixin):
 # Rack roles
 # Rack roles
 #
 #
 
 
-class RackRoleForm(forms.ModelForm, BootstrapMixin):
+class RackRoleForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -208,7 +211,7 @@ class RackFromCSVForm(forms.ModelForm):
             ))
             ))
 
 
 
 
-class RackImportForm(BulkImportForm, BootstrapMixin):
+class RackImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=RackFromCSVForm)
     csv = CSVDataField(csv_form=RackFromCSVForm)
 
 
 
 
@@ -242,7 +245,7 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Manufacturers
 # Manufacturers
 #
 #
 
 
-class ManufacturerForm(forms.ModelForm, BootstrapMixin):
+class ManufacturerForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -254,16 +257,16 @@ class ManufacturerForm(forms.ModelForm, BootstrapMixin):
 # Device types
 # Device types
 #
 #
 
 
-class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
+class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField(slug_source='model')
     slug = SlugField(slug_source='model')
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
         fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
-                  'is_pdu', 'is_network_device', 'subdevice_role']
+                  'is_pdu', 'is_network_device', 'subdevice_role', 'comments']
 
 
 
 
-class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin):
+class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
     u_height = forms.IntegerField(min_value=1, required=False)
     u_height = forms.IntegerField(min_value=1, required=False)
@@ -272,7 +275,8 @@ class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin):
         nullable_fields = []
         nullable_fields = []
 
 
 
 
-class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
+class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = DeviceType
     manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
     manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
                                      to_field_name='slug')
                                      to_field_name='slug')
 
 
@@ -281,44 +285,76 @@ class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
 # Device component templates
 # Device component templates
 #
 #
 
 
-class ConsolePortTemplateForm(forms.ModelForm, BootstrapMixin):
-    name_pattern = ExpandableNameField(label='Name')
+class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
-        fields = ['name_pattern']
+        fields = ['device_type', 'name']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
 
 
 
 
-class ConsoleServerPortTemplateForm(forms.ModelForm, BootstrapMixin):
+class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
+
+class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
-        fields = ['name_pattern']
+        fields = ['device_type', 'name']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
 
 
 
 
-class PowerPortTemplateForm(forms.ModelForm, BootstrapMixin):
+class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
+
+class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
-        fields = ['name_pattern']
+        fields = ['device_type', 'name']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
 
 
 
 
-class PowerOutletTemplateForm(forms.ModelForm, BootstrapMixin):
+class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
+
+class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
-        fields = ['name_pattern']
+        fields = ['device_type', 'name']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
 
 
 
 
-class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
+class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
+
+class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
+
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ['name_pattern', 'form_factor', 'mgmt_only']
+        fields = ['device_type', 'name', 'form_factor', 'mgmt_only']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
+    name_pattern = ExpandableNameField(label='Name')
+    form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
+    mgmt_only = forms.BooleanField(required=False, label='OOB Management')
 
 
 
 
 class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
 class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -329,19 +365,25 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
         nullable_fields = []
         nullable_fields = []
 
 
 
 
-class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
-    name_pattern = ExpandableNameField(label='Name')
+class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
-        fields = ['name_pattern']
+        fields = ['device_type', 'name']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form):
+    name_pattern = ExpandableNameField(label='Name')
 
 
 
 
 #
 #
 # Device roles
 # Device roles
 #
 #
 
 
-class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
+class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -353,7 +395,7 @@ class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
 # Platforms
 # Platforms
 #
 #
 
 
-class PlatformForm(forms.ModelForm, BootstrapMixin):
+class PlatformForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -417,6 +459,10 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
                 ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
                 ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
                 self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
                 self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
 
 
+            # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
+            # can be flipped from one face to another.
+            self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
+
         else:
         else:
 
 
             # An object that doesn't exist yet can't have any IPs assigned to it
             # An object that doesn't exist yet can't have any IPs assigned to it
@@ -566,11 +612,11 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
                 self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
                 self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
 
 
 
 
-class DeviceImportForm(BulkImportForm, BootstrapMixin):
+class DeviceImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=DeviceFromCSVForm)
     csv = CSVDataField(csv_form=DeviceFromCSVForm)
 
 
 
 
-class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
+class ChildDeviceImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
     csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
 
 
 
 
@@ -587,18 +633,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['tenant', 'platform']
         nullable_fields = ['tenant', 'platform']
 
 
 
 
-class DeviceBulkAddComponentForm(forms.Form, BootstrapMixin):
-    pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
-    name_pattern = ExpandableNameField(label='Name')
-
-
-class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
-
-    class Meta:
-        model = Interface
-        fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description']
-
-
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
     model = Device
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
@@ -615,11 +649,27 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     mac_address = forms.CharField(required=False, label='MAC address')
     mac_address = forms.CharField(required=False, label='MAC address')
 
 
 
 
+#
+# Bulk device component creation
+#
+
+class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
+    pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
+    name_pattern = ExpandableNameField(label='Name')
+
+
+class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
+
+    class Meta:
+        model = Interface
+        fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description']
+
+
 #
 #
 # Console ports
 # Console ports
 #
 #
 
 
-class ConsolePortForm(forms.ModelForm, BootstrapMixin):
+class ConsolePortForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
@@ -629,7 +679,7 @@ class ConsolePortForm(forms.ModelForm, BootstrapMixin):
         }
         }
 
 
 
 
-class ConsolePortCreateForm(forms.Form, BootstrapMixin):
+class ConsolePortCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -670,7 +720,7 @@ class ConsoleConnectionCSVForm(forms.Form):
                                             .format(self.cleaned_data['device'], self.cleaned_data['console_port']))
                                             .format(self.cleaned_data['device'], self.cleaned_data['console_port']))
 
 
 
 
-class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
+class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
     csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
 
 
     def clean(self):
     def clean(self):
@@ -700,7 +750,7 @@ class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
         self.cleaned_data['csv'] = connection_list
         self.cleaned_data['csv'] = connection_list
 
 
 
 
-class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
+class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
                                   widget=forms.Select(attrs={'filter-for': 'console_server'}))
                                   widget=forms.Select(attrs={'filter-for': 'console_server'}))
     console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
     console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
@@ -754,7 +804,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
 # Console server ports
 # Console server ports
 #
 #
 
 
-class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
+class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
@@ -764,11 +814,11 @@ class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
         }
         }
 
 
 
 
-class ConsoleServerPortCreateForm(forms.Form, BootstrapMixin):
+class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
-class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
+class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
                                   widget=forms.Select(attrs={'filter-for': 'device'}))
                                   widget=forms.Select(attrs={'filter-for': 'device'}))
     device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
     device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
@@ -816,7 +866,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
 # Power ports
 # Power ports
 #
 #
 
 
-class PowerPortForm(forms.ModelForm, BootstrapMixin):
+class PowerPortForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
@@ -826,7 +876,7 @@ class PowerPortForm(forms.ModelForm, BootstrapMixin):
         }
         }
 
 
 
 
-class PowerPortCreateForm(forms.Form, BootstrapMixin):
+class PowerPortCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -867,7 +917,7 @@ class PowerConnectionCSVForm(forms.Form):
                                             .format(self.cleaned_data['device'], self.cleaned_data['power_port']))
                                             .format(self.cleaned_data['device'], self.cleaned_data['power_port']))
 
 
 
 
-class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
+class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=PowerConnectionCSVForm)
     csv = CSVDataField(csv_form=PowerConnectionCSVForm)
 
 
     def clean(self):
     def clean(self):
@@ -897,7 +947,7 @@ class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
         self.cleaned_data['csv'] = connection_list
         self.cleaned_data['csv'] = connection_list
 
 
 
 
-class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
+class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
                                   widget=forms.Select(attrs={'filter-for': 'pdu'}))
                                   widget=forms.Select(attrs={'filter-for': 'pdu'}))
     pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
     pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
@@ -950,7 +1000,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
 # Power outlets
 # Power outlets
 #
 #
 
 
-class PowerOutletForm(forms.ModelForm, BootstrapMixin):
+class PowerOutletForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
@@ -960,11 +1010,11 @@ class PowerOutletForm(forms.ModelForm, BootstrapMixin):
         }
         }
 
 
 
 
-class PowerOutletCreateForm(forms.Form, BootstrapMixin):
+class PowerOutletCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
-class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
+class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
                                   widget=forms.Select(attrs={'filter-for': 'device'}))
                                   widget=forms.Select(attrs={'filter-for': 'device'}))
     device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
     device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
@@ -1012,7 +1062,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
 # Interfaces
 # Interfaces
 #
 #
 
 
-class InterfaceForm(forms.ModelForm, BootstrapMixin):
+class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
@@ -1022,12 +1072,12 @@ class InterfaceForm(forms.ModelForm, BootstrapMixin):
         }
         }
 
 
 
 
-class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
+class InterfaceCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
-
-    class Meta:
-        model = Interface
-        fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description']
+    form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
+    mac_address = MACAddressFormField(required=False, label='MAC Address')
+    mgmt_only = forms.BooleanField(required=False, label='OOB Management')
+    description = forms.CharField(max_length=100, required=False)
 
 
 
 
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -1043,10 +1093,13 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
 # Interface connections
 # Interface connections
 #
 #
 
 
-class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
+class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
     interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface')
     interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface')
+    site_b = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
+                                    widget=forms.Select(attrs={'filter-for': 'rack_b'}))
     rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
     rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
-                                    widget=forms.Select(attrs={'filter-for': 'device_b'}))
+                                    widget=APISelect(api_url='/api/dcim/racks/?site_id={{site_b}}',
+                                                     attrs={'filter-for': 'device_b'}))
     device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
     device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
                                       widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
                                       widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
                                                        display_field='display_name',
                                                        display_field='display_name',
@@ -1060,21 +1113,27 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = InterfaceConnection
         model = InterfaceConnection
-        fields = ['interface_a', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
+        fields = ['interface_a', 'site_b', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
 
 
     def __init__(self, device_a, *args, **kwargs):
     def __init__(self, device_a, *args, **kwargs):
 
 
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
 
 
-        self.fields['rack_b'].queryset = Rack.objects.filter(site=device_a.rack.site)
-
         # Initialize interface A choices
         # Initialize interface A choices
-        device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL) \
-            .select_related('circuit', 'connected_as_a', 'connected_as_b')
+        device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\
+            .select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
         self.fields['interface_a'].choices = [
         self.fields['interface_a'].choices = [
             (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
             (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
         ]
         ]
 
 
+        # Initialize rack_b choices if site_b is set
+        if self.is_bound and self.data.get('site_b'):
+            self.fields['rack_b'].queryset = Rack.objects.filter(site__pk=self.data['site_b'])
+        elif self.initial.get('site_b'):
+            self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b'])
+        else:
+            self.fields['rack_b'].choices = []
+
         # Initialize device_b choices if rack_b is set
         # Initialize device_b choices if rack_b is set
         if self.is_bound and self.data.get('rack_b'):
         if self.is_bound and self.data.get('rack_b'):
             self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b'])
             self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b'])
@@ -1085,11 +1144,13 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
 
 
         # Initialize interface_b choices if device_b is set
         # Initialize interface_b choices if device_b is set
         if self.is_bound:
         if self.is_bound:
-            device_b_interfaces = Interface.objects.filter(device=self.data['device_b']) \
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
+            device_b_interfaces = Interface.objects.filter(device=self.data['device_b'])\
+                .exclude(form_factor=IFACE_FF_VIRTUAL)\
+                .select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
         elif self.initial.get('device_b'):
         elif self.initial.get('device_b'):
-            device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']) \
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
+            device_b_interfaces = Interface.objects.filter(device=self.initial['device_b'])\
+                .exclude(form_factor=IFACE_FF_VIRTUAL)\
+                .select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
         else:
         else:
             device_b_interfaces = []
             device_b_interfaces = []
         self.fields['interface_b'].choices = [
         self.fields['interface_b'].choices = [
@@ -1139,7 +1200,7 @@ class InterfaceConnectionCSVForm(forms.Form):
                 pass
                 pass
 
 
 
 
-class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
+class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
     csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
 
 
     def clean(self):
     def clean(self):
@@ -1179,7 +1240,7 @@ class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
         self.cleaned_data['csv'] = connection_list
         self.cleaned_data['csv'] = connection_list
 
 
 
 
-class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
+class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form):
     confirm = forms.BooleanField(required=True)
     confirm = forms.BooleanField(required=True)
     # Used for HTTP redirect upon successful deletion
     # Used for HTTP redirect upon successful deletion
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
@@ -1189,7 +1250,7 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
 # Device bays
 # Device bays
 #
 #
 
 
-class DeviceBayForm(forms.ModelForm, BootstrapMixin):
+class DeviceBayForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
@@ -1199,11 +1260,11 @@ class DeviceBayForm(forms.ModelForm, BootstrapMixin):
         }
         }
 
 
 
 
-class DeviceBayCreateForm(forms.Form, BootstrapMixin):
+class DeviceBayCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
-class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
+class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
     installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device',
     installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device',
                                               help_text="Child devices must first be created within the rack occupied "
                                               help_text="Child devices must first be created within the rack occupied "
                                                         "by the parent device. Then they can be assigned to a bay.")
                                                         "by the parent device. Then they can be assigned to a bay.")
@@ -1224,15 +1285,15 @@ class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
 # Connections
 # Connections
 #
 #
 
 
-class ConsoleConnectionFilterForm(forms.Form, BootstrapMixin):
+class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
 
 
 
 
-class PowerConnectionFilterForm(forms.Form, BootstrapMixin):
+class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
 
 
 
 
-class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
+class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
 
 
 
 
@@ -1270,7 +1331,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
 # Modules
 # Modules
 #
 #
 
 
-class ModuleForm(forms.ModelForm, BootstrapMixin):
+class ModuleForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Module
         model = Module

+ 20 - 0
netbox/dcim/migrations/0023_devicetype_comments.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-16 16:08
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0022_color_names_to_rgb'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicetype',
+            name='comments',
+            field=models.TextField(blank=True),
+        ),
+    ]

+ 30 - 0
netbox/dcim/migrations/0024_site_add_contact_fields.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2016-12-29 16:23
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0023_devicetype_comments'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='site',
+            name='contact_email',
+            field=models.EmailField(blank=True, max_length=254, verbose_name=b'Contact E-mail'),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='contact_name',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='contact_phone',
+            field=models.CharField(blank=True, max_length=20),
+        ),
+    ]

+ 26 - 11
netbox/dcim/models.py

@@ -9,6 +9,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 from django.db.models import Count, Q, ObjectDoesNotExist
 
 
+from circuits.models import Circuit
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -244,6 +245,9 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     physical_address = models.CharField(max_length=200, blank=True)
     physical_address = models.CharField(max_length=200, blank=True)
     shipping_address = models.CharField(max_length=200, blank=True)
     shipping_address = models.CharField(max_length=200, blank=True)
+    contact_name = models.CharField(max_length=50, blank=True)
+    contact_phone = models.CharField(max_length=20, blank=True)
+    contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
 
@@ -264,7 +268,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
             self.slug,
             self.slug,
             self.tenant.name if self.tenant else '',
             self.tenant.name if self.tenant else '',
             self.facility,
             self.facility,
-            str(self.asn),
+            str(self.asn) if self.asn else '',
+            self.contact_name,
+            self.contact_phone,
+            self.contact_email,
         ])
         ])
 
 
     @property
     @property
@@ -285,7 +292,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
 
 
     @property
     @property
     def count_circuits(self):
     def count_circuits(self):
-        return self.circuits.count()
+        return Circuit.objects.filter(terminations__site=self).count()
 
 
 
 
 #
 #
@@ -401,6 +408,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             self.get_type_display() if self.type else '',
             self.get_type_display() if self.type else '',
             str(self.width),
             str(self.width),
             str(self.u_height),
             str(self.u_height),
+            'True' if self.desc_units else '',
         ])
         ])
 
 
     @property
     @property
@@ -520,7 +528,7 @@ class Manufacturer(models.Model):
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
 
 
 
 
-class DeviceType(models.Model):
+class DeviceType(models.Model, CustomFieldModel):
     """
     """
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
     well as high-level functional role(s).
     well as high-level functional role(s).
@@ -552,6 +560,8 @@ class DeviceType(models.Model):
                                              choices=SUBDEVICE_ROLE_CHOICES,
                                              choices=SUBDEVICE_ROLE_CHOICES,
                                              help_text="Parent devices house child devices in device bays. Select "
                                              help_text="Parent devices house child devices in device bays. Select "
                                                        "\"None\" if this device type is neither a parent nor a child.")
                                                        "\"None\" if this device type is neither a parent nor a child.")
+    comments = models.TextField(blank=True)
+    custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
 
     class Meta:
     class Meta:
         ordering = ['manufacturer', 'model']
         ordering = ['manufacturer', 'model']
@@ -1136,7 +1146,7 @@ class Interface(models.Model):
     @property
     @property
     def is_connected(self):
     def is_connected(self):
         try:
         try:
-            return bool(self.circuit)
+            return bool(self.circuit_termination)
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             pass
             pass
         return bool(self.connection)
         return bool(self.connection)
@@ -1153,13 +1163,18 @@ class Interface(models.Model):
             pass
             pass
         return None
         return None
 
 
-    def get_connected_interface(self):
-        connection = InterfaceConnection.objects.select_related().filter(Q(interface_a=self) | Q(interface_b=self))\
-            .first()
-        if connection and connection.interface_a == self:
-            return connection.interface_b
-        elif connection:
-            return connection.interface_a
+    @property
+    def connected_interface(self):
+        try:
+            if self.connected_as_a:
+                return self.connected_as_a.interface_b
+        except ObjectDoesNotExist:
+            pass
+        try:
+            if self.connected_as_b:
+                return self.connected_as_b.interface_a
+        except ObjectDoesNotExist:
+            pass
         return None
         return None
 
 
 
 

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

@@ -22,6 +22,9 @@ class SiteTest(APITestCase):
         'asn',
         'asn',
         'physical_address',
         'physical_address',
         'shipping_address',
         'shipping_address',
+        'contact_name',
+        'contact_phone',
+        'contact_email',
         'comments',
         'comments',
         'custom_fields',
         'custom_fields',
         'count_prefixes',
         'count_prefixes',
@@ -233,6 +236,8 @@ class DeviceTypeTest(APITestCase):
         'is_pdu',
         'is_pdu',
         'is_network_device',
         'is_network_device',
         'subdevice_role',
         'subdevice_role',
+        'comments',
+        'custom_fields',
     ]
     ]
 
 
     nested_fields = [
     nested_fields = [

+ 23 - 16
netbox/dcim/urls.py

@@ -1,5 +1,6 @@
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from ipam.views import ServiceEditView
 from secrets.views import secret_add
 from secrets.views import secret_add
 
 
 from . import views
 from . import views
@@ -104,9 +105,11 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
+    url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
 
 
     # Console ports
     # Console ports
-    url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),
+    url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
+    url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'),
     url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
     url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
     url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
     url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
@@ -114,7 +117,8 @@ urlpatterns = [
     url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
 
 
     # Console server ports
     # Console server ports
-    url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'),
+    url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
+    url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
     url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
     url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
     url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
     url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
@@ -122,7 +126,8 @@ urlpatterns = [
     url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
     url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
 
 
     # Power ports
     # Power ports
-    url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'),
+    url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
+    url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'),
     url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
     url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
     url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
     url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
@@ -130,15 +135,27 @@ urlpatterns = [
     url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
 
 
     # Power outlets
     # Power outlets
-    url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'),
+    url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
+    url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
     url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
     url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
     url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
     url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
 
 
+    # Interfaces
+    url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
+    url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'),
+    url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
+    url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
+    url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
+    url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+
     # Device bays
     # Device bays
-    url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
+    url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
+    url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'),
     url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
     url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
     url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
@@ -153,18 +170,8 @@ urlpatterns = [
     url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
     url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
     url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
     url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
 
 
-    # Interfaces
-    url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
-    url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
-    url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
-    url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
-    url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
-    url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
-    url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
-
     # Modules
     # Modules
-    url(r'^devices/(?P<pk>\d+)/modules/add/$', views.module_add, name='module_add'),
+    url(r'^devices/(?P<device>\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'),
     url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
     url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
     url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
     url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
 
 

+ 272 - 403
netbox/dcim/views.py

@@ -6,7 +6,6 @@ from operator import attrgetter
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
-from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.db.models import Count
 from django.db.models import Count
 from django.http import HttpResponseRedirect
 from django.http import HttpResponseRedirect
@@ -14,7 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.http import urlencode
 from django.utils.http import urlencode
 from django.views.generic import View
 from django.views.generic import View
 
 
-from ipam.models import Prefix, IPAddress, VLAN
+from ipam.models import Prefix, IPAddress, Service, VLAN
 from circuits.models import Circuit
 from circuits.models import Circuit
 from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
@@ -57,6 +56,66 @@ def expand_pattern(string):
             yield "{0}{1}".format(lead, i)
             yield "{0}{1}".format(lead, i)
 
 
 
 
+class ComponentCreateView(View):
+    parent_model = None
+    parent_field = None
+    model = None
+    form = None
+    model_form = None
+
+    def get(self, request, pk):
+
+        parent = get_object_or_404(self.parent_model, pk=pk)
+
+        return render(request, 'dcim/device_component_add.html', {
+            'parent': parent,
+            'component_type': self.model._meta.verbose_name,
+            'form': self.form(initial=request.GET),
+            'cancel_url': parent.get_absolute_url(),
+        })
+
+    def post(self, request, pk):
+
+        parent = get_object_or_404(self.parent_model, pk=pk)
+
+        form = self.form(request.POST)
+        if form.is_valid():
+
+            new_components = []
+            data = deepcopy(form.cleaned_data)
+
+            for name in form.cleaned_data['name_pattern']:
+                component_data = {
+                    self.parent_field: parent.pk,
+                    'name': name,
+                }
+                component_data.update(data)
+                component_form = self.model_form(component_data)
+                if component_form.is_valid():
+                    new_components.append(component_form.save(commit=False))
+                else:
+                    for field, errors in component_form.errors.as_data().items():
+                        for e in errors:
+                            form.add_error(field, u'{}: {}'.format(name, ', '.join(e)))
+
+            if not form.errors:
+                self.model.objects.bulk_create(new_components)
+                messages.success(request, u"Added {} {} to {}.".format(
+                    len(new_components), self.model._meta.verbose_name_plural, parent
+                ))
+                if '_addanother' in request.POST:
+                    return redirect(request.path)
+                else:
+                    return redirect(parent.get_absolute_url())
+
+        return render(request, 'dcim/device_component_add.html', {
+            'parent': parent,
+            'component_type': self.model._meta.verbose_name,
+            'form': form,
+            'cancel_url': parent.get_absolute_url(),
+        })
+
+
 #
 #
 # Sites
 # Sites
 #
 #
@@ -78,7 +137,7 @@ def site(request, slug):
         'device_count': Device.objects.filter(rack__site=site).count(),
         'device_count': Device.objects.filter(rack__site=site).count(),
         'prefix_count': Prefix.objects.filter(site=site).count(),
         'prefix_count': Prefix.objects.filter(site=site).count(),
         'vlan_count': VLAN.objects.filter(site=site).count(),
         'vlan_count': VLAN.objects.filter(site=site).count(),
-        'circuit_count': Circuit.objects.filter(site=site).count(),
+        'circuit_count': Circuit.objects.filter(terminations__site=site).count(),
     }
     }
     rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
     rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
     topology_maps = TopologyMap.objects.filter(site=site)
     topology_maps = TopologyMap.objects.filter(site=site)
@@ -331,6 +390,7 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_devicetype'
     permission_required = 'dcim.change_devicetype'
     model = DeviceType
     model = DeviceType
     form_class = forms.DeviceTypeForm
     form_class = forms.DeviceTypeForm
+    template_name = 'dcim/devicetype_edit.html'
     obj_list_url = 'dcim:devicetype_list'
     obj_list_url = 'dcim:devicetype_list'
 
 
 
 
@@ -358,69 +418,30 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device type components
 # Device type components
 #
 #
 
 
-class ComponentTemplateCreateView(View):
-    model = None
-    form = None
-
-    def get(self, request, pk):
-
-        devicetype = get_object_or_404(DeviceType, pk=pk)
-
-        return render(request, 'dcim/component_template_add.html', {
-            'devicetype': devicetype,
-            'component_type': self.model._meta.verbose_name,
-            'form': self.form(initial=request.GET),
-            'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
-        })
-
-    def post(self, request, pk):
-
-        devicetype = get_object_or_404(DeviceType, pk=pk)
-
-        form = self.form(request.POST)
-        if form.is_valid():
-
-            component_templates = []
-            for name in form.cleaned_data['name_pattern']:
-                component_template = self.form(request.POST).save(commit=False)
-                component_template.device_type = devicetype
-                component_template.name = name
-                try:
-                    component_template.full_clean()
-                    component_templates.append(component_template)
-                except ValidationError:
-                    form.add_error('name_pattern', "Duplicate name found: {}".format(name))
-
-            if not form.errors:
-                self.model.objects.bulk_create(component_templates)
-                messages.success(request, u"Added {} component(s) to {}.".format(len(component_templates), devicetype))
-                if '_addanother' in request.POST:
-                    return redirect(request.path)
-                else:
-                    return redirect('dcim:devicetype', pk=devicetype.pk)
-
-        return render(request, 'dcim/component_template_add.html', {
-            'devicetype': devicetype,
-            'component_type': self.model._meta.verbose_name,
-            'form': form,
-            'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
-        })
-
-
-class ConsolePortTemplateAddView(ComponentTemplateCreateView):
+class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_consoleporttemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
     model = ConsolePortTemplate
     model = ConsolePortTemplate
-    form = forms.ConsolePortTemplateForm
+    form = forms.ConsolePortTemplateCreateForm
+    model_form = forms.ConsolePortTemplateForm
 
 
 
 
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleporttemplate'
     permission_required = 'dcim.delete_consoleporttemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
     cls = ConsolePortTemplate
     cls = ConsolePortTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class ConsoleServerPortTemplateAddView(ComponentTemplateCreateView):
+class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_consoleserverporttemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
-    form = forms.ConsoleServerPortTemplateForm
+    form = forms.ConsoleServerPortTemplateCreateForm
+    model_form = forms.ConsoleServerPortTemplateForm
 
 
 
 
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -429,9 +450,13 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class PowerPortTemplateAddView(ComponentTemplateCreateView):
+class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_powerporttemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
     model = PowerPortTemplate
     model = PowerPortTemplate
-    form = forms.PowerPortTemplateForm
+    form = forms.PowerPortTemplateCreateForm
+    model_form = forms.PowerPortTemplateForm
 
 
 
 
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -440,9 +465,13 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class PowerOutletTemplateAddView(ComponentTemplateCreateView):
+class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_poweroutlettemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
     model = PowerOutletTemplate
     model = PowerOutletTemplate
-    form = forms.PowerOutletTemplateForm
+    form = forms.PowerOutletTemplateCreateForm
+    model_form = forms.PowerOutletTemplateForm
 
 
 
 
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -451,9 +480,13 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class InterfaceTemplateAddView(ComponentTemplateCreateView):
+class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_interfacetemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
     model = InterfaceTemplate
     model = InterfaceTemplate
-    form = forms.InterfaceTemplateForm
+    form = forms.InterfaceTemplateCreateForm
+    model_form = forms.InterfaceTemplateForm
 
 
 
 
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -470,9 +503,13 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class DeviceBayTemplateAddView(ComponentTemplateCreateView):
+class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_devicebaytemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
     model = DeviceBayTemplate
     model = DeviceBayTemplate
-    form = forms.DeviceBayTemplateForm
+    form = forms.DeviceBayTemplateCreateForm
+    model_form = forms.DeviceBayTemplateForm
 
 
 
 
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -560,21 +597,26 @@ def device(request, pk):
     power_outlets = natsorted(
     power_outlets = natsorted(
         PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
         PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
     )
     )
-    interfaces = Interface.objects.filter(device=device, mgmt_only=False)\
-        .select_related('connected_as_a', 'connected_as_b', 'circuit')
-    mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
-        .select_related('connected_as_a', 'connected_as_b', 'circuit')
+    interfaces = Interface.objects.filter(device=device, mgmt_only=False).select_related(
+        'connected_as_a__interface_b__device',
+        'connected_as_b__interface_a__device',
+        'circuit_termination__circuit',
+    )
+    mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True).select_related(
+        'connected_as_a__interface_b__device',
+        'connected_as_b__interface_a__device',
+        'circuit_termination__circuit',
+    )
     device_bays = natsorted(
     device_bays = natsorted(
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         key=attrgetter('name')
         key=attrgetter('name')
     )
     )
 
 
-    # Gather any secrets which belong to this device
-    secrets = device.secrets.all()
-
-    # Find all IP addresses assigned to this device
+    # Gather relevant device objects
     ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
     ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
         .order_by('address')
         .order_by('address')
+    services = Service.objects.filter(device=device)
+    secrets = device.secrets.all()
 
 
     # Find any related devices for convenient linking in the UI
     # Find any related devices for convenient linking in the UI
     related_devices = []
     related_devices = []
@@ -604,6 +646,7 @@ def device(request, pk):
         'mgmt_interfaces': mgmt_interfaces,
         'mgmt_interfaces': mgmt_interfaces,
         'device_bays': device_bays,
         'device_bays': device_bays,
         'ip_addresses': ip_addresses,
         'ip_addresses': ip_addresses,
+        'services': services,
         'secrets': secrets,
         'secrets': secrets,
         'related_devices': related_devices,
         'related_devices': related_devices,
         'show_graphs': show_graphs,
         'show_graphs': show_graphs,
@@ -687,121 +730,17 @@ def device_lldp_neighbors(request, pk):
     })
     })
 
 
 
 
-class DeviceBulkAddComponentView(View):
-    """
-    Add one or more components (e.g. interfaces) to a selected set of Devices.
-    """
-    form = None
-    component_cls = None
-    component_form = None
-
-    def get(self):
-        return redirect('dcim:device_list')
-
-    def post(self, request):
-
-        # Are we editing *all* objects in the queryset or just a selected subset?
-        if request.POST.get('_all'):
-            pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
-        else:
-            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
-
-        if '_create' in request.POST:
-            form = self.form(request.POST)
-            if form.is_valid():
-
-                new_components = []
-                data = deepcopy(form.cleaned_data)
-                for device in data['pk']:
-
-                    names = data['name_pattern']
-                    for name in names:
-                        component_data = {
-                            'device': device.pk,
-                            'name': name,
-                        }
-                        component_data.update(data)
-                        component_form = self.component_form(component_data)
-                        if component_form.is_valid():
-                            new_components.append(component_form.save(commit=False))
-                        else:
-                            form.add_error('name_pattern', "Duplicate {} name for {}: {}".format(
-                                self.component_cls._meta.verbose_name, device, name
-                            ))
-
-                if not form.errors:
-                    self.component_cls.objects.bulk_create(new_components)
-                    messages.success(request, u"Added {} {} to {} devices.".format(
-                        len(new_components), self.component_cls._meta.verbose_name_plural, len(form.cleaned_data['pk'])
-                    ))
-                    return redirect('dcim:device_list')
-
-        else:
-            form = self.form(initial={'pk': pk_list})
-
-        selected_devices = Device.objects.filter(pk__in=pk_list)
-        if not selected_devices:
-            messages.warning(request, u"No devices were selected.")
-            return redirect('dcim:device_list')
-
-        return render(request, 'dcim/device_bulk_add_component.html', {
-            'form': form,
-            'component_name': self.component_cls._meta.verbose_name_plural,
-            'selected_devices': selected_devices,
-            'cancel_url': reverse('dcim:device_list'),
-        })
-
-
-class DeviceBulkAddInterfaceView(DeviceBulkAddComponentView):
-    """
-    Add one or more components (e.g. interfaces) to a selected set of Devices.
-    """
-    form = forms.DeviceBulkAddInterfaceForm
-    component_cls = Interface
-    component_form = forms.InterfaceForm
-
-
 #
 #
 # Console ports
 # Console ports
 #
 #
 
 
-@permission_required('dcim.add_consoleport')
-def consoleport_add(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.ConsolePortCreateForm(request.POST)
-        if form.is_valid():
-
-            console_ports = []
-            for name in form.cleaned_data['name_pattern']:
-                cp_form = forms.ConsolePortForm({
-                    'device': device.pk,
-                    'name': name,
-                })
-                if cp_form.is_valid():
-                    console_ports.append(cp_form.save(commit=False))
-                else:
-                    form.add_error('name_pattern', "Duplicate console port name for this device: {}".format(name))
-
-            if not form.errors:
-                ConsolePort.objects.bulk_create(console_ports)
-                messages.success(request, u"Added {} console port(s) to {}.".format(len(console_ports), device))
-                if '_addanother' in request.POST:
-                    return redirect('dcim:consoleport_add', pk=device.pk)
-                else:
-                    return redirect('dcim:device', pk=device.pk)
-
-    else:
-        form = forms.ConsolePortCreateForm()
-
-    return render(request, 'dcim/device_component_add.html', {
-        'device': device,
-        'component_type': 'Console Port',
-        'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
-    })
+class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_consoleport'
+    parent_model = Device
+    parent_field = 'device'
+    model = ConsolePort
+    form = forms.ConsolePortCreateForm
+    model_form = forms.ConsolePortForm
 
 
 
 
 @permission_required('dcim.change_consoleport')
 @permission_required('dcim.change_consoleport')
@@ -891,44 +830,13 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 # Console server ports
 # Console server ports
 #
 #
 
 
-@permission_required('dcim.add_consoleserverport')
-def consoleserverport_add(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.ConsoleServerPortCreateForm(request.POST)
-        if form.is_valid():
-
-            cs_ports = []
-            for name in form.cleaned_data['name_pattern']:
-                csp_form = forms.ConsoleServerPortForm({
-                    'device': device.pk,
-                    'name': name,
-                })
-                if csp_form.is_valid():
-                    cs_ports.append(csp_form.save(commit=False))
-                else:
-                    form.add_error('name_pattern', "Duplicate console server port name for this device: {}"
-                                   .format(name))
-
-            if not form.errors:
-                ConsoleServerPort.objects.bulk_create(cs_ports)
-                messages.success(request, u"Added {} console server port(s) to {}.".format(len(cs_ports), device))
-                if '_addanother' in request.POST:
-                    return redirect('dcim:consoleserverport_add', pk=device.pk)
-                else:
-                    return redirect('dcim:device', pk=device.pk)
-
-    else:
-        form = forms.ConsoleServerPortCreateForm()
-
-    return render(request, 'dcim/device_component_add.html', {
-        'device': device,
-        'component_type': 'Console Server Port',
-        'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
-    })
+class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_consoleserverport'
+    parent_model = Device
+    parent_field = 'device'
+    model = ConsoleServerPort
+    form = forms.ConsoleServerPortCreateForm
+    model_form = forms.ConsoleServerPortForm
 
 
 
 
 @permission_required('dcim.change_consoleserverport')
 @permission_required('dcim.change_consoleserverport')
@@ -1012,43 +920,13 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Power ports
 # Power ports
 #
 #
 
 
-@permission_required('dcim.add_powerport')
-def powerport_add(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.PowerPortCreateForm(request.POST)
-        if form.is_valid():
-
-            power_ports = []
-            for name in form.cleaned_data['name_pattern']:
-                pp_form = forms.PowerPortForm({
-                    'device': device.pk,
-                    'name': name,
-                })
-                if pp_form.is_valid():
-                    power_ports.append(pp_form.save(commit=False))
-                else:
-                    form.add_error('name_pattern', "Duplicate power port name for this device: {}".format(name))
-
-            if not form.errors:
-                PowerPort.objects.bulk_create(power_ports)
-                messages.success(request, u"Added {} power port(s) to {}.".format(len(power_ports), device))
-                if '_addanother' in request.POST:
-                    return redirect('dcim:powerport_add', pk=device.pk)
-                else:
-                    return redirect('dcim:device', pk=device.pk)
-
-    else:
-        form = forms.PowerPortCreateForm()
-
-    return render(request, 'dcim/device_component_add.html', {
-        'device': device,
-        'component_type': 'Power Port',
-        'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
-    })
+class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_powerport'
+    parent_model = Device
+    parent_field = 'device'
+    model = PowerPort
+    form = forms.PowerPortCreateForm
+    model_form = forms.PowerPortForm
 
 
 
 
 @permission_required('dcim.change_powerport')
 @permission_required('dcim.change_powerport')
@@ -1138,43 +1016,13 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 # Power outlets
 # Power outlets
 #
 #
 
 
-@permission_required('dcim.add_poweroutlet')
-def poweroutlet_add(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.PowerOutletCreateForm(request.POST)
-        if form.is_valid():
-
-            power_outlets = []
-            for name in form.cleaned_data['name_pattern']:
-                po_form = forms.PowerOutletForm({
-                    'device': device.pk,
-                    'name': name,
-                })
-                if po_form.is_valid():
-                    power_outlets.append(po_form.save(commit=False))
-                else:
-                    form.add_error('name_pattern', "Duplicate power outlet name for this device: {}".format(name))
-
-            if not form.errors:
-                PowerOutlet.objects.bulk_create(power_outlets)
-                messages.success(request, u"Added {} power outlet(s) to {}.".format(len(power_outlets), device))
-                if '_addanother' in request.POST:
-                    return redirect('dcim:poweroutlet_add', pk=device.pk)
-                else:
-                    return redirect('dcim:device', pk=device.pk)
-
-    else:
-        form = forms.PowerOutletCreateForm()
-
-    return render(request, 'dcim/device_component_add.html', {
-        'device': device,
-        'component_type': 'Power Outlet',
-        'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
-    })
+class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_poweroutlet'
+    parent_model = Device
+    parent_field = 'device'
+    model = PowerOutlet
+    form = forms.PowerOutletCreateForm
+    model_form = forms.PowerOutletForm
 
 
 
 
 @permission_required('dcim.change_poweroutlet')
 @permission_required('dcim.change_poweroutlet')
@@ -1257,47 +1105,13 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Interfaces
 # Interfaces
 #
 #
 
 
-@permission_required('dcim.add_interface')
-def interface_add(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.InterfaceCreateForm(request.POST)
-        if form.is_valid():
-
-            interfaces = []
-            for name in form.cleaned_data['name_pattern']:
-                iface_form = forms.InterfaceForm({
-                    'device': device.pk,
-                    'name': name,
-                    'form_factor': form.cleaned_data['form_factor'],
-                    'mac_address': form.cleaned_data['mac_address'],
-                    'mgmt_only': form.cleaned_data['mgmt_only'],
-                    'description': form.cleaned_data['description'],
-                })
-                if iface_form.is_valid():
-                    interfaces.append(iface_form.save(commit=False))
-                else:
-                    form.add_error('name_pattern', "Duplicate interface name for this device: {}".format(name))
-
-            if not form.errors:
-                Interface.objects.bulk_create(interfaces)
-                messages.success(request, u"Added {} interface(s) to {}.".format(len(interfaces), device))
-                if '_addanother' in request.POST:
-                    return redirect('dcim:interface_add', pk=device.pk)
-                else:
-                    return redirect('dcim:device', pk=device.pk)
-
-    else:
-        form = forms.InterfaceCreateForm(initial={'mgmt_only': request.GET.get('mgmt_only')})
-
-    return render(request, 'dcim/device_component_add.html', {
-        'device': device,
-        'component_type': 'Interface',
-        'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
-    })
+class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_interface'
+    parent_model = Device
+    parent_field = 'device'
+    model = Interface
+    form = forms.InterfaceCreateForm
+    model_form = forms.InterfaceForm
 
 
 
 
 class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
 class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
@@ -1329,44 +1143,13 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device bays
 # Device bays
 #
 #
 
 
-@permission_required('dcim.add_devicebay')
-def devicebay_add(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.DeviceBayCreateForm(request.POST)
-        if form.is_valid():
-
-            device_bays = []
-            for name in form.cleaned_data['name_pattern']:
-                devicebay_form = forms.DeviceBayForm({
-                    'device': device.pk,
-                    'name': name,
-                })
-                if devicebay_form.is_valid():
-                    device_bays.append(devicebay_form.save(commit=False))
-                else:
-                    for err in devicebay_form.errors.get('__all__', []):
-                        form.add_error('name_pattern', err)
-
-            if not form.errors:
-                DeviceBay.objects.bulk_create(device_bays)
-                messages.success(request, u"Added {} device bay(s) to {}.".format(len(device_bays), device))
-                if '_addanother' in request.POST:
-                    return redirect('dcim:devicebay_add', pk=device.pk)
-                else:
-                    return redirect('dcim:device', pk=device.pk)
-
-    else:
-        form = forms.DeviceBayCreateForm()
-
-    return render(request, 'dcim/device_component_add.html', {
-        'device': device,
-        'component_type': 'Device Bay',
-        'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
-    })
+class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_devicebay'
+    parent_model = Device
+    parent_field = 'device'
+    model = DeviceBay
+    form = forms.DeviceBayCreateForm
+    model_form = forms.DeviceBayForm
 
 
 
 
 class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
 class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
@@ -1436,6 +1219,112 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     parent_cls = Device
     parent_cls = Device
 
 
 
 
+#
+# Bulk device component creation
+#
+
+class DeviceBulkAddComponentView(View):
+    """
+    Add one or more components (e.g. interfaces) to a selected set of Devices.
+    """
+    form = forms.DeviceBulkAddComponentForm
+    model = None
+    model_form = None
+
+    def get(self):
+        return redirect('dcim:device_list')
+
+    def post(self, request):
+
+        # Are we editing *all* objects in the queryset or just a selected subset?
+        if request.POST.get('_all'):
+            pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
+        else:
+            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
+
+        if '_create' in request.POST:
+            form = self.form(request.POST)
+            if form.is_valid():
+
+                new_components = []
+                data = deepcopy(form.cleaned_data)
+                for device in data['pk']:
+
+                    names = data['name_pattern']
+                    for name in names:
+                        component_data = {
+                            'device': device.pk,
+                            'name': name,
+                        }
+                        component_data.update(data)
+                        component_form = self.model_form(component_data)
+                        if component_form.is_valid():
+                            new_components.append(component_form.save(commit=False))
+                        else:
+                            for field, errors in component_form.errors.as_data().items():
+                                for e in errors:
+                                    form.add_error(field, u'{} {}: {}'.format(device, name, ', '.join(e)))
+
+                if not form.errors:
+                    self.model.objects.bulk_create(new_components)
+                    messages.success(request, u"Added {} {} to {} devices.".format(
+                        len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk'])
+                    ))
+                    return redirect('dcim:device_list')
+
+        else:
+            form = self.form(initial={'pk': pk_list})
+
+        selected_devices = Device.objects.filter(pk__in=pk_list)
+        if not selected_devices:
+            messages.warning(request, u"No devices were selected.")
+            return redirect('dcim:device_list')
+
+        return render(request, 'dcim/device_bulk_add_component.html', {
+            'form': form,
+            'component_name': self.model._meta.verbose_name_plural,
+            'selected_devices': selected_devices,
+            'cancel_url': reverse('dcim:device_list'),
+        })
+
+
+class DeviceBulkAddConsolePortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
+    permission_required = 'dcim.add_consoleport'
+    model = ConsolePort
+    model_form = forms.ConsolePortForm
+
+
+class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
+    permission_required = 'dcim.add_consoleserverport'
+    model = ConsoleServerPort
+    model_form = forms.ConsoleServerPortForm
+
+
+class DeviceBulkAddPowerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
+    permission_required = 'dcim.add_powerport'
+    model = PowerPort
+    model_form = forms.PowerPortForm
+
+
+class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, DeviceBulkAddComponentView):
+    permission_required = 'dcim.add_poweroutlet'
+    model = PowerOutlet
+    model_form = forms.PowerOutletForm
+
+
+class DeviceBulkAddInterfaceView(PermissionRequiredMixin, DeviceBulkAddComponentView):
+    permission_required = 'dcim.add_interface'
+    form = forms.DeviceBulkAddInterfaceForm
+    model = Interface
+    model_form = forms.InterfaceForm
+
+
+class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, DeviceBulkAddComponentView):
+    permission_required = 'dcim.add_devicebay'
+    model = DeviceBay
+    model_form = forms.DeviceBayForm
+
+
 #
 #
 # Interface connections
 # Interface connections
 #
 #
@@ -1467,9 +1356,11 @@ def interfaceconnection_add(request, pk):
 
 
     else:
     else:
         form = forms.InterfaceConnectionForm(device, initial={
         form = forms.InterfaceConnectionForm(device, initial={
-            'interface_a': request.GET.get('interface', None),
+            'interface_a': request.GET.get('interface_a', None),
+            'site_b': request.GET.get('site_b', device.rack.site),
             'rack_b': request.GET.get('rack_b', None),
             'rack_b': request.GET.get('rack_b', None),
             'device_b': request.GET.get('device_b', None),
             'device_b': request.GET.get('device_b', None),
+            'interface_b': request.GET.get('interface_b', None),
         })
         })
 
 
     return render(request, 'dcim/interfaceconnection_edit.html', {
     return render(request, 'dcim/interfaceconnection_edit.html', {
@@ -1602,39 +1493,17 @@ def ipaddress_assign(request, pk):
 # Modules
 # Modules
 #
 #
 
 
-@permission_required('dcim.add_module')
-def module_add(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.ModuleForm(request.POST)
-        if form.is_valid():
-            module = form.save(commit=False)
-            module.device = device
-            module.save()
-            messages.success(request, u"Added module {} to {}".format(module.name, module.device.name))
-            if '_addanother' in request.POST:
-                return redirect('dcim:module_add', pk=module.device.pk)
-            else:
-                return redirect('dcim:device_inventory', pk=module.device.pk)
-
-    else:
-        form = forms.ModuleForm()
-
-    return render(request, 'dcim/device_component_add.html', {
-        'device': device,
-        'component_type': 'Module',
-        'form': form,
-        'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': device.pk}),
-    })
-
-
 class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
 class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_module'
     permission_required = 'dcim.change_module'
     model = Module
     model = Module
     form_class = forms.ModuleForm
     form_class = forms.ModuleForm
 
 
+    def alter_obj(self, obj, args, kwargs):
+        if 'device' in kwargs:
+            device = get_object_or_404(Device, pk=kwargs['device'])
+            obj.device = device
+        return obj
+
 
 
 class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_module'
     permission_required = 'dcim.delete_module'

+ 3 - 2
netbox/extras/forms.py

@@ -44,7 +44,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
 
 
         # Date
         # Date
         elif cf.type == CF_TYPE_DATE:
         elif cf.type == CF_TYPE_DATE:
-            field = forms.DateField(required=cf.required, initial=cf.default)
+            field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD")
 
 
         # Select
         # Select
         elif cf.type == CF_TYPE_SELECT:
         elif cf.type == CF_TYPE_SELECT:
@@ -63,7 +63,8 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
 
 
         field.model = cf
         field.model = cf
         field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
         field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
-        field.help_text = cf.description
+        if cf.description:
+            field.help_text = cf.description
 
 
         field_dict[field_name] = field
         field_dict[field_name] = field
 
 

+ 1 - 1
netbox/extras/models.py

@@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
 
 
 
 
 CUSTOMFIELD_MODELS = (
 CUSTOMFIELD_MODELS = (
-    'site', 'rack', 'device',                               # DCIM
+    'site', 'rack', 'devicetype', 'device',                 # DCIM
     'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf',      # IPAM
     'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf',      # IPAM
     'provider', 'circuit',                                  # Circuits
     'provider', 'circuit',                                  # Circuits
     'tenant',                                               # Tenants
     'tenant',                                               # Tenants

+ 22 - 3
netbox/ipam/api/serializers.py

@@ -1,8 +1,8 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
+from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer
 from extras.api.serializers import CustomFieldSerializer
 from extras.api.serializers import CustomFieldSerializer
-from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
+from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from tenancy.api.serializers import TenantNestedSerializer
 from tenancy.api.serializers import TenantNestedSerializer
 
 
 
 
@@ -138,7 +138,7 @@ class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description',
+        fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
                   'custom_fields']
                   'custom_fields']
 
 
 
 
@@ -170,3 +170,22 @@ class IPAddressNestedSerializer(IPAddressSerializer):
 
 
 IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
 IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
 IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()
 IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()
+
+
+#
+# Services
+#
+
+class ServiceSerializer(serializers.ModelSerializer):
+    device = DeviceNestedSerializer()
+    ipaddresses = IPAddressNestedSerializer(many=True)
+
+    class Meta:
+        model = Service
+        fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
+
+
+class ServiceNestedSerializer(ServiceSerializer):
+
+    class Meta(ServiceSerializer.Meta):
+        fields = ['id', 'name', 'port', 'protocol']

+ 4 - 0
netbox/ipam/api/urls.py

@@ -37,4 +37,8 @@ urlpatterns = [
     url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
     url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
 
 
+    # Services
+    url(r'^services/$', ServiceListView.as_view(), name='service_list'),
+    url(r'^services/(?P<pk>\d+)/$', ServiceDetailView.as_view(), name='service_detail'),
+
 ]
 ]

+ 22 - 1
netbox/ipam/api/views.py

@@ -1,6 +1,6 @@
 from rest_framework import generics
 from rest_framework import generics
 
 
-from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
+from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam import filters
 from ipam import filters
 
 
 from extras.api.views import CustomFieldModelAPIView
 from extras.api.views import CustomFieldModelAPIView
@@ -177,3 +177,24 @@ class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
         .prefetch_related('custom_field_values__field')
         .prefetch_related('custom_field_values__field')
     serializer_class = serializers.VLANSerializer
     serializer_class = serializers.VLANSerializer
+
+
+#
+# Services
+#
+
+class ServiceListView(generics.ListAPIView):
+    """
+    List services (filterable)
+    """
+    queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
+    serializer_class = serializers.ServiceSerializer
+    filter_class = filters.ServiceFilter
+
+
+class ServiceDetailView(generics.RetrieveAPIView):
+    """
+    Retrieve a single service
+    """
+    queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
+    serializer_class = serializers.ServiceSerializer

+ 28 - 11
netbox/ipam/filters.py

@@ -9,7 +9,7 @@ from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter
 from utilities.filters import NullableModelMultipleChoiceFilter
 
 
-from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
+from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
 
 
 class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -43,7 +43,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd']
+        fields = ['rd']
 
 
 
 
 class RIRFilter(django_filters.FilterSet):
 class RIRFilter(django_filters.FilterSet):
@@ -64,7 +64,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='RIR (ID)',
         label='RIR (ID)',
     )
     )
     rir = django_filters.ModelMultipleChoiceFilter(
     rir = django_filters.ModelMultipleChoiceFilter(
-        name='rir',
+        name='rir__slug',
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='RIR (slug)',
         label='RIR (slug)',
@@ -72,7 +72,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
-        fields = ['family', 'rir_id', 'rir', 'date_added']
+        fields = ['family', 'date_added']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         qs_filter = Q(description__icontains=value)
         qs_filter = Q(description__icontains=value)
@@ -149,7 +149,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
+        fields = ['family', 'status']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         qs_filter = Q(description__icontains=value)
         qs_filter = Q(description__icontains=value)
@@ -226,7 +226,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Device (ID)',
         label='Device (ID)',
     )
     )
     device = django_filters.ModelMultipleChoiceFilter(
     device = django_filters.ModelMultipleChoiceFilter(
-        name='interface__device',
+        name='interface__device__name',
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
         label='Device (name)',
         label='Device (name)',
@@ -239,7 +239,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['q', 'family', 'status', 'device_id', 'device', 'interface_id']
+        fields = ['q', 'family', 'status']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         qs_filter = Q(description__icontains=value)
         qs_filter = Q(description__icontains=value)
@@ -268,7 +268,7 @@ class VLANGroupFilter(django_filters.FilterSet):
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -276,7 +276,6 @@ class VLANGroupFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['site_id', 'site']
 
 
 
 
 class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -290,7 +289,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -340,7 +339,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['site_id', 'site', 'vid', 'name', 'status', 'role_id', 'role']
+        fields = ['status']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
         qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
@@ -349,3 +348,21 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         except ValueError:
         except ValueError:
             pass
             pass
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
+
+
+class ServiceFilter(django_filters.FilterSet):
+    device_id = django_filters.ModelMultipleChoiceFilter(
+        name='device',
+        queryset=Device.objects.all(),
+        label='Device (ID)',
+    )
+    device = django_filters.ModelMultipleChoiceFilter(
+        name='device__name',
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        label='Device (name)',
+    )
+
+    class Meta:
+        model = Service
+        fields = ['name', 'protocol', 'port']

+ 46 - 23
netbox/ipam/forms.py

@@ -5,12 +5,13 @@ from dcim.models import Site, Rack, Device, Interface
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, add_blank_choice,
+    APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
+    SlugField, add_blank_choice,
 )
 )
 
 
 from .models import (
 from .models import (
-    Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup,
-    VLAN_STATUS_CHOICES, VRF,
+    Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
+    VLANGroup, VLAN_STATUS_CHOICES, VRF,
 )
 )
 
 
 
 
@@ -47,7 +48,7 @@ class VRFFromCSVForm(forms.ModelForm):
         fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
         fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
 
 
-class VRFImportForm(BulkImportForm, BootstrapMixin):
+class VRFImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=VRFFromCSVForm)
     csv = CSVDataField(csv_form=VRFFromCSVForm)
 
 
 
 
@@ -70,7 +71,7 @@ class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # RIRs
 # RIRs
 #
 #
 
 
-class RIRForm(forms.ModelForm, BootstrapMixin):
+class RIRForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -78,7 +79,7 @@ class RIRForm(forms.ModelForm, BootstrapMixin):
         fields = ['name', 'slug', 'is_private']
         fields = ['name', 'slug', 'is_private']
 
 
 
 
-class RIRFilterForm(forms.Form, BootstrapMixin):
+class RIRFilterForm(BootstrapMixin, forms.Form):
     is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
     is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
         ('', '---------'),
         ('', '---------'),
         ('True', 'Yes'),
         ('True', 'Yes'),
@@ -111,7 +112,7 @@ class AggregateFromCSVForm(forms.ModelForm):
         fields = ['prefix', 'rir', 'date_added', 'description']
         fields = ['prefix', 'rir', 'date_added', 'description']
 
 
 
 
-class AggregateImportForm(BulkImportForm, BootstrapMixin):
+class AggregateImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=AggregateFromCSVForm)
     csv = CSVDataField(csv_form=AggregateFromCSVForm)
 
 
 
 
@@ -136,7 +137,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Roles
 # Roles
 #
 #
 
 
-class RoleForm(forms.ModelForm, BootstrapMixin):
+class RoleForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -157,15 +158,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description']
-        help_texts = {
-            'prefix': "IPv4 or IPv6 network",
-            'vrf': "VRF (if applicable)",
-            'site': "The site to which this prefix is assigned (if applicable)",
-            'vlan': "The VLAN to which this prefix is assigned (if applicable)",
-            'status': "Operational status of this prefix",
-            'role': "The primary function of this prefix",
-        }
+        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description']
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(PrefixForm, self).__init__(*args, **kwargs)
         super(PrefixForm, self).__init__(*args, **kwargs)
@@ -196,7 +189,7 @@ class PrefixFromCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role',
+        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool',
                   'description']
                   'description']
 
 
     def clean(self):
     def clean(self):
@@ -235,7 +228,7 @@ class PrefixFromCSVForm(forms.ModelForm):
         return super(PrefixFromCSVForm, self).save(*args, **kwargs)
         return super(PrefixFromCSVForm, self).save(*args, **kwargs)
 
 
 
 
-class PrefixImportForm(BulkImportForm, BootstrapMixin):
+class PrefixImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=PrefixFromCSVForm)
     csv = CSVDataField(csv_form=PrefixFromCSVForm)
 
 
 
 
@@ -339,6 +332,14 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                 self.fields['nat_inside'].choices = []
                 self.fields['nat_inside'].choices = []
 
 
 
 
+class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
+    address = ExpandableIPAddressField()
+    vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
+    description = forms.CharField(max_length=100, required=False)
+
+
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
     site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
                                   widget=forms.Select(attrs={'filter-for': 'rack'}))
                                   widget=forms.Select(attrs={'filter-for': 'rack'}))
@@ -417,7 +418,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
         return super(IPAddressFromCSVForm, self).save(*args, **kwargs)
         return super(IPAddressFromCSVForm, self).save(*args, **kwargs)
 
 
 
 
-class IPAddressImportForm(BulkImportForm, BootstrapMixin):
+class IPAddressImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=IPAddressFromCSVForm)
     csv = CSVDataField(csv_form=IPAddressFromCSVForm)
 
 
 
 
@@ -456,7 +457,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # VLAN groups
 # VLAN groups
 #
 #
 
 
-class VLANGroupForm(forms.ModelForm, BootstrapMixin):
+class VLANGroupForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -464,7 +465,7 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin):
         fields = ['site', 'name', 'slug']
         fields = ['site', 'name', 'slug']
 
 
 
 
-class VLANGroupFilterForm(forms.Form, BootstrapMixin):
+class VLANGroupFilterForm(BootstrapMixin, forms.Form):
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
 
 
 
 
@@ -529,7 +530,7 @@ class VLANFromCSVForm(forms.ModelForm):
         return m
         return m
 
 
 
 
-class VLANImportForm(BulkImportForm, BootstrapMixin):
+class VLANImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=VLANFromCSVForm)
     csv = CSVDataField(csv_form=VLANFromCSVForm)
 
 
 
 
@@ -563,3 +564,25 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
     status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
     role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
     role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
                              null_option=(0, 'None'))
                              null_option=(0, 'None'))
+
+
+#
+# Services
+#
+
+class ServiceForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = Service
+        fields = ['name', 'protocol', 'port', 'ipaddresses', 'description']
+        help_texts = {
+            'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
+                           "reachable via all IPs assigned to the device.",
+        }
+
+    def __init__(self, *args, **kwargs):
+
+        super(ServiceForm, self).__init__(*args, **kwargs)
+
+        # Limit IP address choices to those assigned to interfaces of the parent device
+        self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)

+ 39 - 0
netbox/ipam/migrations/0012_services.py

@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-15 20:22
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0022_color_names_to_rgb'),
+        ('ipam', '0011_rir_add_is_private'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Service',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateField(auto_now_add=True)),
+                ('last_updated', models.DateTimeField(auto_now=True)),
+                ('name', models.CharField(max_length=30)),
+                ('protocol', models.PositiveSmallIntegerField(choices=[(6, b'TCP'), (17, b'UDP')])),
+                ('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name=b'Port number')),
+                ('description', models.CharField(blank=True, max_length=100)),
+                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name=b'device')),
+                ('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name=b'IP addresses')),
+            ],
+            options={
+                'ordering': ['device', 'protocol', 'port'],
+            },
+        ),
+        migrations.AlterUniqueTogether(
+            name='service',
+            unique_together=set([('device', 'protocol', 'port')]),
+        ),
+    ]

+ 37 - 0
netbox/ipam/migrations/0013_prefix_add_is_pool.py

@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2016-12-27 19:34
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import ipam.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0012_services'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='prefix',
+            name='is_pool',
+            field=models.BooleanField(default=False, help_text=b'All IP addresses within this prefix are considered usable', verbose_name=b'Is a pool'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='prefix',
+            field=ipam.fields.IPNetworkField(help_text=b'IPv4 or IPv6 network with mask'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='role',
+            field=models.ForeignKey(blank=True, help_text=b'The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[(0, b'Container'), (1, b'Active'), (2, b'Reserved'), (3, b'Deprecated')], default=1, help_text=b'Operational status of this prefix', verbose_name=b'Status'),
+        ),
+    ]

+ 43 - 3
netbox/ipam/models.py

@@ -61,6 +61,14 @@ STATUS_CHOICE_CLASSES = {
 }
 }
 
 
 
 
+IP_PROTOCOL_TCP = 6
+IP_PROTOCOL_UDP = 17
+IP_PROTOCOL_CHOICES = (
+    (IP_PROTOCOL_TCP, 'TCP'),
+    (IP_PROTOCOL_UDP, 'UDP'),
+)
+
+
 class VRF(CreatedUpdatedModel, CustomFieldModel):
 class VRF(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -261,15 +269,19 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
     assigned to a VLAN where appropriate.
     assigned to a VLAN where appropriate.
     """
     """
     family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
     family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
-    prefix = IPNetworkField()
+    prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask")
     site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
     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,
     vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
                             verbose_name='VRF')
                             verbose_name='VRF')
     tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
     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,
     vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
                              verbose_name='VLAN')
                              verbose_name='VLAN')
-    status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1)
-    role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
+    status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE,
+                                              help_text="Operational status of this prefix")
+    role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True,
+                             help_text="The primary function of this prefix")
+    is_pool = models.BooleanField(verbose_name='Is a pool', default=False,
+                                  help_text="All IP addresses within this prefix are considered usable")
     description = models.CharField(max_length=100, blank=True)
     description = models.CharField(max_length=100, blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
 
@@ -312,8 +324,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
             self.vrf.rd if self.vrf else '',
             self.vrf.rd if self.vrf else '',
             self.tenant.name if self.tenant else '',
             self.tenant.name if self.tenant else '',
             self.site.name if self.site else '',
             self.site.name if self.site else '',
+            self.vlan.group.name if self.vlan and self.vlan.group else '',
+            str(self.vlan.vid) if self.vlan else '',
             self.get_status_display(),
             self.get_status_display(),
             self.role.name if self.role else '',
             self.role.name if self.role else '',
+            'True' if self.is_pool else '',
             self.description,
             self.description,
         ])
         ])
 
 
@@ -525,3 +540,28 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
 
 
     def get_status_class(self):
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]
         return STATUS_CHOICE_CLASSES[self.status]
+
+
+class Service(CreatedUpdatedModel):
+    """
+    A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
+    to one or more specific IPAddresses belonging to the Device.
+    """
+    device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
+    name = models.CharField(max_length=30)
+    protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
+    port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
+                                       verbose_name='Port number')
+    ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
+                                         verbose_name='IP addresses')
+    description = models.CharField(max_length=100, blank=True)
+
+    class Meta:
+        ordering = ['device', 'protocol', 'port']
+        unique_together = ['device', 'protocol', 'port']
+
+    def __unicode__(self):
+        return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
+
+    def get_parent_url(self):
+        return self.device.get_absolute_url()

+ 31 - 5
netbox/ipam/tables.py

@@ -58,6 +58,14 @@ PREFIX_LINK_BRIEF = """
 </span>
 </span>
 """
 """
 
 
+PREFIX_ROLE_LINK = """
+{% if record.role %}
+    <a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 IPADDRESS_LINK = """
 IPADDRESS_LINK = """
 {% if record.pk %}
 {% if record.pk %}
     <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
     <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
@@ -86,6 +94,22 @@ STATUS_LABEL = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+VLAN_PREFIXES = """
+{% for prefix in record.prefixes.all %}
+    <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
+{% empty %}
+    &mdash;
+{% endfor %}
+"""
+
+VLAN_ROLE_LINK = """
+{% if record.role %}
+    <a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 VLANGROUP_ACTIONS = """
 VLANGROUP_ACTIONS = """
 {% if perms.ipam.change_vlangroup %}
 {% if perms.ipam.change_vlangroup %}
     <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>
     <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>
@@ -189,16 +213,17 @@ class RoleTable(BaseTable):
 class PrefixTable(BaseTable):
 class PrefixTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
+    prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}})
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    role = tables.Column(verbose_name='Role')
+    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
+    role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
     description = tables.Column(orderable=False, verbose_name='Description')
     description = tables.Column(orderable=False, verbose_name='Description')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Prefix
         model = Prefix
-        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description')
+        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
         row_attrs = {
         row_attrs = {
             'class': lambda record: 'success' if not record.pk else '',
             'class': lambda record: 'success' if not record.pk else '',
         }
         }
@@ -281,10 +306,11 @@ class VLANTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     name = tables.Column(verbose_name='Name')
     name = tables.Column(verbose_name='Name')
+    prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    role = tables.Column(verbose_name='Role')
+    role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLAN
         model = VLAN
-        fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role')
+        fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role')

+ 5 - 0
netbox/ipam/urls.py

@@ -51,6 +51,7 @@ urlpatterns = [
     # IP addresses
     # IP addresses
     url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
     url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
     url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'),
     url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'),
+    url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_add'),
     url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
     url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
     url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
     url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
@@ -76,4 +77,8 @@ urlpatterns = [
     url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
     url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
     url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
     url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
 
 
+    # Services
+    url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
+    url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
+
 ]
 ]

+ 43 - 17
netbox/ipam/views.py

@@ -12,11 +12,14 @@ from dcim.models import Device
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
 from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
 
 
 from . import filters, forms, tables
 from . import filters, forms, tables
-from .models import Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, VLAN, VLANGroup, VRF
+from .models import (
+    Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
+    Service, VLAN, VLANGroup, VRF,
+)
 
 
 
 
 def add_available_prefixes(parent, prefix_list):
 def add_available_prefixes(parent, prefix_list):
@@ -35,24 +38,21 @@ def add_available_prefixes(parent, prefix_list):
     return prefix_list
     return prefix_list
 
 
 
 
-def add_available_ipaddresses(prefix, ipaddress_list):
+def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
     """
     """
-    Annotate ranges of available IP addresses within a given prefix.
+    Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be
+    considered usable (regardless of mask length).
     """
     """
 
 
     output = []
     output = []
     prev_ip = None
     prev_ip = None
 
 
-    # Ignore the "network address" for IPv4 prefixes larger than /31
-    if prefix.version == 4 and prefix.prefixlen < 31:
+    # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31.
+    if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool:
         first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
         first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
-    else:
-        first_ip_in_prefix = netaddr.IPAddress(prefix.first)
-
-    # Ignore the broadcast address for IPv4 prefixes larger than /31
-    if prefix.version == 4 and prefix.prefixlen < 31:
         last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
         last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
     else:
     else:
+        first_ip_in_prefix = netaddr.IPAddress(prefix.first)
         last_ip_in_prefix = netaddr.IPAddress(prefix.last)
         last_ip_in_prefix = netaddr.IPAddress(prefix.last)
 
 
     if not ipaddress_list:
     if not ipaddress_list:
@@ -290,7 +290,6 @@ def aggregate(request, pk):
     child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
     child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
 
 
     prefix_table = tables.PrefixTable(child_prefixes)
     prefix_table = tables.PrefixTable(child_prefixes)
-    prefix_table.model = Prefix
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
         prefix_table.base_columns['pk'].visible = True
         prefix_table.base_columns['pk'].visible = True
     RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
     RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
@@ -367,7 +366,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class PrefixListView(ObjectListView):
 class PrefixListView(ObjectListView):
-    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     filter_form = forms.PrefixFilterForm
     table = tables.PrefixTable
     table = tables.PrefixTable
@@ -416,7 +415,6 @@ def prefix(request, pk):
     if child_prefixes:
     if child_prefixes:
         child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
         child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
     child_prefix_table = tables.PrefixTable(child_prefixes)
     child_prefix_table = tables.PrefixTable(child_prefixes)
-    child_prefix_table.model = Prefix
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
         child_prefix_table.base_columns['pk'].visible = True
         child_prefix_table.base_columns['pk'].visible = True
     RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
     RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
@@ -475,10 +473,9 @@ def prefix_ipaddresses(request, pk):
     # Find all IPAddresses belonging to this Prefix
     # Find all IPAddresses belonging to this Prefix
     ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, 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')
         .select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
-    ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses)
+    ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
 
 
     ip_table = tables.IPAddressTable(ipaddresses)
     ip_table = tables.IPAddressTable(ipaddresses)
-    ip_table.model = IPAddress
     if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
     if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
         ip_table.base_columns['pk'].visible = True
         ip_table.base_columns['pk'].visible = True
     RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
     RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
@@ -610,6 +607,14 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     redirect_url = 'ipam:ipaddress_list'
     redirect_url = 'ipam:ipaddress_list'
 
 
 
 
+class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
+    permission_required = 'ipam.add_ipaddress'
+    form = forms.IPAddressBulkAddForm
+    model = IPAddress
+    template_name = 'ipam/ipaddress_bulk_add.html'
+    redirect_url = 'ipam:ipaddress_list'
+
+
 class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
 class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
     permission_required = 'ipam.add_ipaddress'
     permission_required = 'ipam.add_ipaddress'
     form = forms.IPAddressImportForm
     form = forms.IPAddressImportForm
@@ -679,7 +684,7 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class VLANListView(ObjectListView):
 class VLANListView(ObjectListView):
-    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
+    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     filter_form = forms.VLANFilterForm
     table = tables.VLANTable
     table = tables.VLANTable
@@ -733,3 +738,24 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlan'
     permission_required = 'ipam.delete_vlan'
     cls = VLAN
     cls = VLAN
     default_redirect_url = 'ipam:vlan_list'
     default_redirect_url = 'ipam:vlan_list'
+
+
+#
+# Services
+#
+
+class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.change_service'
+    model = Service
+    form_class = forms.ServiceForm
+    template_name = 'ipam/service_edit.html'
+
+    def alter_obj(self, obj, args, kwargs):
+        if 'device' in kwargs:
+            obj.device = get_object_or_404(Device, pk=kwargs['device'])
+        return obj
+
+
+class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'ipam.delete_service'
+    model = Service

+ 9 - 2
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.7.3'
+VERSION = '1.8.0'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -117,7 +117,8 @@ INSTALLED_APPS = (
 )
 )
 
 
 # Middleware
 # Middleware
-MIDDLEWARE_CLASSES = (
+MIDDLEWARE = (
+    'debug_toolbar.middleware.DebugToolbarMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
@@ -193,6 +194,12 @@ SWAGGER_SETTINGS = {
     'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH),
     'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH),
 }
 }
 
 
+# Django debug toolbar
+INTERNAL_IPS = (
+    '127.0.0.1',
+    '::1',
+)
+
 
 
 try:
 try:
     HOSTNAME = socket.gethostname()
     HOSTNAME = socket.gethostname()

+ 6 - 0
netbox/netbox/urls.py

@@ -42,6 +42,12 @@ _patterns = [
 
 
 ]
 ]
 
 
+if settings.DEBUG:
+    import debug_toolbar
+    _patterns += [
+        url(r'^__debug__/', include(debug_toolbar.urls)),
+    ]
+
 # Prepend BASE_PATH
 # Prepend BASE_PATH
 urlpatterns = [
 urlpatterns = [
     url(r'^{}'.format(settings.BASE_PATH), include(_patterns))
     url(r'^{}'.format(settings.BASE_PATH), include(_patterns))

+ 12 - 4
netbox/project-static/css/base.css

@@ -13,7 +13,7 @@ body {
 }
 }
 .container {
 .container {
     width: auto;
     width: auto;
-    max-width: 1340px;
+    max-width: 1600px;
 }
 }
 .wrapper {
 .wrapper {
     min-height: 100%;
     min-height: 100%;
@@ -35,7 +35,8 @@ footer p {
     margin: 20px 0;
     margin: 20px 0;
 }
 }
 
 
-@media (max-width: 1200px) {
+/* Collapse the nav menu on displays less than 1200px wide */
+@media (max-width: 1199px) {
     .navbar-header {
     .navbar-header {
         float: none;
         float: none;
     }
     }
@@ -58,7 +59,7 @@ footer p {
         max-height: none;
         max-height: none;
     }
     }
     .navbar-nav {
     .navbar-nav {
-        float: none!important;
+        float: none !important;
         margin-top: 7.5px;
         margin-top: 7.5px;
     }
     }
     .navbar-nav>li {
     .navbar-nav>li {
@@ -88,10 +89,17 @@ th.pk, td.pk {
 tfoot td {
 tfoot td {
     font-weight: bold;
     font-weight: bold;
 }
 }
+table.attr-table td:nth-child(1) {
+    width: 25%;
+}
 
 
 /* Paginator */
 /* Paginator */
+div.paginator {
+    margin-bottom: 20px;
+}
 nav ul.pagination {
 nav ul.pagination {
     margin-top: 0;
     margin-top: 0;
+    margin-bottom: 8px !important;
 }
 }
 
 
 /* Racks */
 /* Racks */
@@ -322,4 +330,4 @@ td .progress {
 }
 }
 textarea {
 textarea {
     font-family: Consolas, Lucida Console, monospace;
     font-family: Consolas, Lucida Console, monospace;
-}
+}

+ 8 - 0
netbox/project-static/js/forms.js

@@ -51,6 +51,14 @@ $(document).ready(function() {
         $('#id_' + this.value).toggle('disabled');
         $('#id_' + this.value).toggle('disabled');
     });
     });
 
 
+    // Set formaction and submit using a link
+    $('a.formaction').click(function (event) {
+        event.preventDefault();
+        var form = $(this).closest('form');
+        form.attr('action', $(this).attr('href'));
+        form.submit();
+    });
+
     // API select widget
     // API select widget
     $('select[filter-for]').change(function () {
     $('select[filter-for]').change(function () {
 
 

+ 2 - 2
netbox/secrets/filters.py

@@ -17,7 +17,7 @@ class SecretFilter(django_filters.FilterSet):
         label='Role (ID)',
         label='Role (ID)',
     )
     )
     role = django_filters.ModelMultipleChoiceFilter(
     role = django_filters.ModelMultipleChoiceFilter(
-        name='role',
+        name='role__slug',
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
@@ -31,7 +31,7 @@ class SecretFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret
-        fields = ['name', 'role_id', 'role', 'device']
+        fields = ['name']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         return queryset.filter(
         return queryset.filter(

+ 6 - 6
netbox/secrets/forms.py

@@ -34,7 +34,7 @@ def validate_rsa_key(key, is_secret=True):
 # Secret roles
 # Secret roles
 #
 #
 
 
-class SecretRoleForm(forms.ModelForm, BootstrapMixin):
+class SecretRoleForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -46,7 +46,7 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin):
 # Secrets
 # Secrets
 #
 #
 
 
-class SecretForm(forms.ModelForm, BootstrapMixin):
+class SecretForm(BootstrapMixin, forms.ModelForm):
     private_key = forms.CharField(required=False, widget=forms.HiddenInput())
     private_key = forms.CharField(required=False, widget=forms.HiddenInput())
     plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
     plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
                                 widget=forms.PasswordInput(attrs={'class': 'requires-private-key'}))
                                 widget=forms.PasswordInput(attrs={'class': 'requires-private-key'}))
@@ -85,12 +85,12 @@ class SecretFromCSVForm(forms.ModelForm):
         return s
         return s
 
 
 
 
-class SecretImportForm(BulkImportForm, BootstrapMixin):
+class SecretImportForm(BootstrapMixin, BulkImportForm):
     private_key = forms.CharField(widget=forms.HiddenInput())
     private_key = forms.CharField(widget=forms.HiddenInput())
     csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
     csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
 
 
 
 
-class SecretBulkEditForm(BulkEditForm, BootstrapMixin):
+class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
     role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
     role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
     name = forms.CharField(max_length=100, required=False)
     name = forms.CharField(max_length=100, required=False)
@@ -99,7 +99,7 @@ class SecretBulkEditForm(BulkEditForm, BootstrapMixin):
         nullable_fields = ['name']
         nullable_fields = ['name']
 
 
 
 
-class SecretFilterForm(forms.Form, BootstrapMixin):
+class SecretFilterForm(BootstrapMixin, forms.Form):
     role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
     role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
 
 
 
 
@@ -107,7 +107,7 @@ class SecretFilterForm(forms.Form, BootstrapMixin):
 # UserKeys
 # UserKeys
 #
 #
 
 
-class UserKeyForm(forms.ModelForm, BootstrapMixin):
+class UserKeyForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = UserKey
         model = UserKey

+ 8 - 75
netbox/templates/circuits/circuit.html

@@ -5,14 +5,14 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></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><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
             <li>{{ circuit.cid }}</li>
             <li>{{ circuit.cid }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'circuits:circuit_list' %}" method="get">
         <form action="{% url 'circuits:circuit_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" />
                 <input type="text" name="q" class="form-control" />
@@ -40,13 +40,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ circuit.provider }} - {{ circuit.cid }}</h1>
 <h1>{{ circuit.provider }} - {{ circuit.cid }}</h1>
+{% include 'inc/created_updated.html' with obj=circuit %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Circuit</strong>
                 <strong>Circuit</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Provider</td>
                     <td>Provider</td>
                     <td>
                     <td>
@@ -81,17 +82,6 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Speed</td>
-                    <td>
-                        {% if circuit.upstream_speed %}
-                            <i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }} &nbsp;
-                            <i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
-                        {% else %}
-                            {{ circuit.port_speed_human }}
-                        {% endif %}
-                    </td>
-                </tr>
                 <tr>
                 <tr>
                     <td>Commit Rate</td>
                     <td>Commit Rate</td>
                     <td>
                     <td>
@@ -107,67 +97,6 @@
         {% with circuit.get_custom_fields as custom_fields %}
         {% with circuit.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% endwith %}
-        {% include 'inc/created_updated.html' with obj=circuit %}
-	</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>IP Addressing</td>
-                    <td>
-                        {% if circuit.interface %}
-                            {% for ip in circuit.interface.ip_addresses.all %}
-                                {% if not forloop.first %}<br />{% endif %}
-                                <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
-                            {% empty %}
-                                <span class="text-muted">None</span>
-                            {% endfor %}
-                        {% else %}
-                            <span class="text-muted">N/A</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 panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
@@ -180,6 +109,10 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
+	</div>
+	<div class="col-md-6">
+        {% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %}
+        {% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 0 - 34
netbox/templates/circuits/circuit_edit.html

@@ -1,5 +1,4 @@
 {% extends 'utilities/obj_edit.html' %}
 {% extends 'utilities/obj_edit.html' %}
-{% load static from staticfiles %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block form %}
 {% block form %}
@@ -11,15 +10,6 @@
             {% render_field form.type %}
             {% render_field form.type %}
             {% render_field form.tenant %}
             {% render_field form.tenant %}
             {% render_field form.install_date %}
             {% render_field form.install_date %}
-            {% 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.upstream_speed %}
             {% render_field form.commit_rate %}
             {% render_field form.commit_rate %}
         </div>
         </div>
     </div>
     </div>
@@ -31,26 +21,6 @@
             </div>
             </div>
         </div>
         </div>
     {% endif %}
     {% endif %}
-    <div class="panel panel-default">
-        <div class="panel-heading"><strong>Termination</strong></div>
-        <div class="panel-body">
-            {% render_field form.site %}
-            <ul class="nav nav-tabs" role="tablist">
-                <li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
-                <li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
-            </ul>
-            <div class="tab-content">
-                <div class="tab-pane active" id="select">
-                    {% render_field form.rack %}
-                    {% render_field form.device %}
-                </div>
-                <div class="tab-pane" id="search">
-                    {% render_field form.livesearch %}
-                </div>
-            </div>
-            {% render_field form.interface %}
-        </div>
-    </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Comments</strong></div>
         <div class="panel-heading"><strong>Comments</strong></div>
         <div class="panel-body">
         <div class="panel-body">
@@ -58,7 +28,3 @@
         </div>
         </div>
     </div>
     </div>
 {% endblock %}
 {% endblock %}
-
-{% block javascript %}
-<script src="{% static 'js/livesearch.js' %}"></script>
-{% endblock %}

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

@@ -48,45 +48,20 @@
 					<td>Name of tenant (optional)</td>
 					<td>Name of tenant (optional)</td>
 					<td>Strickland Propane</td>
 					<td>Strickland Propane</td>
 				</tr>
 				</tr>
-				<tr>
-					<td>Site</td>
-					<td>Site name</td>
-					<td>ASH-4</td>
-				</tr>
 				<tr>
 				<tr>
 					<td>Install Date</td>
 					<td>Install Date</td>
 					<td>Date in YYYY-MM-DD format (optional)</td>
 					<td>Date in YYYY-MM-DD format (optional)</td>
 					<td>2016-02-23</td>
 					<td>2016-02-23</td>
 				</tr>
 				</tr>
-				<tr>
-					<td>Port Speed</td>
-					<td>Physical speed in Kbps</td>
-					<td>100000</td>
-				</tr>
-				<tr>
-					<td>Upstream Speed</td>
-					<td>Upstream speed in Kbps (optional)</td>
-					<td>20000</td>
-				</tr>
 				<tr>
 				<tr>
 					<td>Commit rate</td>
 					<td>Commit rate</td>
 					<td>Commited rate in Kbps (optional)</td>
 					<td>Commited rate in Kbps (optional)</td>
 					<td>2000</td>
 					<td>2000</td>
 				</tr>
 				</tr>
-				<tr>
-					<td>Cross-connect ID</td>
-					<td>ID of cross-connect (optional)</td>
-					<td>937649</td>
-				</tr>
-				<tr>
-					<td>Patch Panel</td>
-					<td>Patch panel/port ID (optional)</td>
-					<td>PP8371 ports 13/14</td>
-				</tr>
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14</pre>
+		<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 25 - 0
netbox/templates/circuits/circuit_terminations_swap.html

@@ -0,0 +1,25 @@
+{% extends 'utilities/confirmation_form.html' %}
+
+{% block title %}Swap Circuit Terminations{% endblock %}
+
+{% block message %}
+    <p>Swap these terminations for circuit {{ circuit }}?</p>
+    <ul>
+        <li>
+            <strong>A side:</strong>
+            {% if termination_a %}
+                {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
+            {% else %}
+                <span class="text-muted">None</span>
+            {% endif %}
+        </li>
+        <li>
+            <strong>Z side:</strong>
+            {% if termination_z %}
+                {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
+            {% else %}
+                <span class="text-muted">None</span>
+            {% endif %}
+        </li>
+    </ul>
+{% endblock %}

+ 94 - 0
netbox/templates/circuits/circuittermination_edit.html

@@ -0,0 +1,94 @@
+{% extends '_base.html' %}
+{% load staticfiles %}
+{% load form_helpers %}
+
+{% block title %}
+    Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}
+{% endblock %}
+
+{% block content %}
+    <form action="." method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {% for field in form.hidden_fields %}
+            {{ field }}
+        {% endfor %}
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3">
+                <h3>Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}</h3>
+                {% if form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Location</strong></div>
+                    <div class="panel-body">
+                        <div class="form-group">
+                            <label class="col-md-3 control-label">Provider</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ obj.circuit.provider }}</p>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label class="col-md-3 control-label">Circuit</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ obj.circuit.cid }}</p>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label class="col-md-3 control-label">Termination</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ form.term_side.value }}</p>
+                            </div>
+                        </div>
+                        {% render_field form.site %}
+                        <div class="row">
+                            <div class="col-md-9 col-md-offset-3">
+                                <ul class="nav nav-tabs" role="tablist">
+                                    <li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
+                                    <li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
+                                </ul>
+                            </div>
+                        </div>
+                        <div class="tab-content">
+                            <div class="tab-pane active" id="select">
+                                {% render_field form.rack %}
+                                {% render_field form.device %}
+                            </div>
+                            <div class="tab-pane" id="search">
+                                {% render_field form.livesearch %}
+                            </div>
+                        </div>
+                        {% render_field form.interface %}
+                    </div>
+                </div>
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Termination Details</strong></div>
+                    <div class="panel-body">
+                        {% render_field form.port_speed %}
+                        {% render_field form.upstream_speed %}
+                        {% render_field form.xconnect_id %}
+                        {% render_field form.pp_info %}
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3 text-right">
+                {% if obj.pk %}
+                    <button type="submit" name="_update" class="btn btn-primary">Update</button>
+                {% else %}
+                    <button type="submit" name="_create" class="btn btn-primary">Create</button>
+                {% endif %}
+                <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    </form>
+{% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/livesearch.js' %}"></script>
+{% endblock %}

+ 95 - 0
netbox/templates/circuits/inc/circuit_termination.html

@@ -0,0 +1,95 @@
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <div class="pull-right">
+            {% if not termination and perms.circuits.add_circuittermination %}
+                <a href="{% url 'circuits:circuittermination_add' circuit=circuit.pk %}?term_side={{ side }}" class="btn btn-xs btn-success">
+                    <span class="fa fa-plus" aria-hidden="true"></span> Add
+                </a>
+            {% endif %}
+            {% if termination and perms.circuits.change_circuittermination %}
+                <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-xs btn-warning">
+                    <span class="fa fa-pencil" aria-hidden="true"></span> Edit
+                </a>
+                <a href="{% url 'circuits:circuit_terminations_swap' pk=circuit.pk %}" class="btn btn-xs btn-primary">
+                    <span class="fa fa-refresh" aria-hidden="true"></span> Swap
+                </a>
+            {% endif %}
+            {% if termination and perms.circuits.delete_circuittermination %}
+                <a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}" class="btn btn-xs btn-danger">
+                    <span class="fa fa-trash" aria-hidden="true"></span> Delete
+                </a>
+            {% endif %}
+        </div>
+        <strong>Termination - {{ side }} Side</strong>
+    </div>
+    {% if termination %}
+        <table class="table table-hover panel-body attr-table">
+            <tr>
+                <td>Site</td>
+                <td>
+                    <a href="{% url 'dcim:site' slug=termination.site.slug %}">{{ termination.site }}</a>
+                </td>
+            </tr>
+            <tr>
+                <td>Termination</td>
+                <td>
+                    {% if termination.interface %}
+                        <span><a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a> {{ termination.interface }}</span>
+                    {% else %}
+                        <span class="text-muted">Not defined</span>
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>Speed</td>
+                <td>
+                    {% if termination.upstream_speed %}
+                        <i class="fa fa-arrow-down" title="Downstream"></i> {{ termination.port_speed_human }} &nbsp;
+                        <i class="fa fa-arrow-up" title="Upstream"></i> {{ termination.upstream_speed_human }}
+                    {% else %}
+                        {{ termination.port_speed_human }}
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>IP Addressing</td>
+                <td>
+                    {% if termination.interface %}
+                        {% for ip in termination.interface.ip_addresses.all %}
+                            {% if not forloop.first %}<br />{% endif %}
+                            <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
+                        {% empty %}
+                            <span class="text-muted">None</span>
+                        {% endfor %}
+                    {% else %}
+                        <span class="text-muted">N/A</span>
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>Cross-Connect</td>
+                <td>
+                    {% if termination.xconnect_id %}
+                        {{ termination.xconnect_id }}
+                    {% else %}
+                        <span class="text-muted">N/A</span>
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>Patch Panel/Port</td>
+                <td>
+                    {% if termination.pp_info %}
+                        {{ termination.pp_info }}
+                    {% else %}
+                        <span class="text-muted">N/A</span>
+                    {% endif %}
+                </td>
+            </tr>
+        </table>
+    {% else %}
+        <div class="panel-body">
+            <span class="text-muted">None</span>
+        </div>
+    {% endif %}
+</div>

+ 12 - 11
netbox/templates/circuits/provider.html

@@ -6,13 +6,13 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
             <li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
             <li>{{ provider }}</li>
             <li>{{ provider }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'circuits:provider_list' %}" method="get">
         <form action="{% url 'circuits:provider_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" />
                 <input type="text" name="q" class="form-control" />
@@ -46,13 +46,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ provider }}</h1>
 <h1>{{ provider }}</h1>
+{% include 'inc/created_updated.html' with obj=provider %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Provider</strong>
                 <strong>Provider</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>ASN</td>
                     <td>ASN</td>
                     <td>
                     <td>
@@ -120,7 +121,6 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
-        {% include 'inc/created_updated.html' with obj=provider %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -134,14 +134,8 @@
                             <a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
                             <a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
                         </td>
                         </td>
                         <td>
                         <td>
-                            <a href="{% url 'dcim:site' slug=c.site.slug %}">{{ c.site }}</a>
-                        </td>
-                        <td>
-                            {% if c.interface %}
-                                <a href="{% url 'dcim:device' pk=c.interface.device.pk %}">{{ c.interface.device }}</a>
-                            {% endif %}
+                            <a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
                         </td>
                         </td>
-                        <td>{{ c.port_speed_human }}</td>
                     </tr>
                     </tr>
                 {% empty %}
                 {% empty %}
                     <tr>
                     <tr>
@@ -149,6 +143,13 @@
                     </tr>
                     </tr>
                 {% endfor %}
                 {% endfor %}
             </table>
             </table>
+            {% if perms.circuits.add_circuit %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add circuit
+                    </a>
+                </div>
+            {% endif %}
         </div>
         </div>
     </div>
     </div>
 </div>
 </div>

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

@@ -1,13 +0,0 @@
-{% extends 'utilities/confirmation_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Delete device type components?{% endblock %}
-
-{% block message %}
-    <p>Are you sure you want to delete these components from <strong>{{ devicetype }}</strong>?</p>
-    <ul>
-        {% for o in selected_objects %}
-            <li>{{ o }}</li>
-        {% endfor %}
-    </ul>
-{% endblock %}

+ 47 - 17
netbox/templates/dcim/device.html

@@ -8,12 +8,12 @@
 {% block content %}
 {% block content %}
 {% include 'dcim/inc/_device_header.html' with active_tab='info' %}
 {% include 'dcim/inc/_device_header.html' with active_tab='info' %}
 <div class="row">
 <div class="row">
-	<div class="col-md-6">
+	<div class="col-md-5 col-lg-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Device</strong>
                 <strong>Device</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Tenant</td>
                     <td>Tenant</td>
                     <td>
                     <td>
@@ -85,7 +85,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Management</strong>
                 <strong>Management</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Role</td>
                     <td>Role</td>
                     <td>
                     <td>
@@ -205,6 +205,29 @@
                 {% endif %}
                 {% endif %}
             {% endif %}
             {% endif %}
         </div>
         </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Services</strong>
+            </div>
+            {% if services %}
+                <table class="table table-hover panel-body">
+                    {% for service in services %}
+                        {% include 'dcim/inc/_service.html' %}
+                    {% endfor %}
+                </table>
+            {% else %}
+                <div class="panel-body text-muted">
+                    None
+                </div>
+            {% endif %}
+            {% if perms.dcim.add_service %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
+                    </a>
+                </div>
+            {% endif %}
+        </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Critical Connections</strong>
                 <strong>Critical Connections</strong>
@@ -301,9 +324,8 @@
                 <div class="panel-body text-muted">None found</div>
                 <div class="panel-body text-muted">None found</div>
             {% endif %}
             {% endif %}
         </div>
         </div>
-        {% include 'inc/created_updated.html' with obj=device %}
 	</div>
 	</div>
-	<div class="col-md-6">
+	<div class="col-md-7 col-lg-6">
         {% if device_bays or device.device_type.is_parent_device %}
         {% if device_bays or device.device_type.is_parent_device %}
             {% if perms.dcim.delete_devicebay %}
             {% if perms.dcim.delete_devicebay %}
                 <form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
                 <form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
@@ -313,9 +335,11 @@
                 <div class="panel-heading">
                 <div class="panel-heading">
                     <strong>Device Bays</strong>
                     <strong>Device Bays</strong>
                     <div class="pull-right">
                     <div class="pull-right">
-                        <button class="btn btn-default btn-xs toggle">
-                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                        </button>
+                        {% if perms.dcim.change_devicebay and device_bays|length > 1 %}
+                            <button class="btn btn-default btn-xs toggle">
+                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                            </button>
+                        {% endif %}
                         {% if perms.dcim.add_devicebay and device_bays|length > 10 %}
                         {% if perms.dcim.add_devicebay and device_bays|length > 10 %}
                             <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                             <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
@@ -363,9 +387,11 @@
                 <div class="panel-heading">
                 <div class="panel-heading">
                     <strong>Interfaces</strong>
                     <strong>Interfaces</strong>
                     <div class="pull-right">
                     <div class="pull-right">
-                        <button class="btn btn-default btn-xs toggle">
-                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                        </button>
+                        {% if perms.dcim.change_interface and interfaces|length > 1 %}
+                            <button class="btn btn-default btn-xs toggle">
+                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                            </button>
+                        {% endif %}
                         {% if perms.dcim.add_interface and interfaces|length > 10 %}
                         {% if perms.dcim.add_interface and interfaces|length > 10 %}
                             <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                             <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
@@ -418,9 +444,11 @@
                 <div class="panel-heading">
                 <div class="panel-heading">
                     <strong>Console Server Ports</strong>
                     <strong>Console Server Ports</strong>
                     <div class="pull-right">
                     <div class="pull-right">
-                        <button class="btn btn-default btn-xs toggle">
-                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                        </button>
+                        {% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
+                            <button class="btn btn-default btn-xs toggle">
+                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                            </button>
+                        {% endif %}
                         {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
                         {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
                             <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                             <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
@@ -468,9 +496,11 @@
                 <div class="panel-heading">
                 <div class="panel-heading">
                     <strong>Power Outlets</strong>
                     <strong>Power Outlets</strong>
                     <div class="pull-right">
                     <div class="pull-right">
-                        <button class="btn btn-default btn-xs toggle">
-                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
-                        </button>
+                        {% if perms.dcim.change_poweroutlet and cs_ports|length > 1 %}
+                            <button class="btn btn-default btn-xs toggle">
+                                <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                            </button>
+                        {% endif %}
                         {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
                         {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
                             <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                             <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets

+ 4 - 4
netbox/templates/dcim/device_component_add.html

@@ -1,9 +1,9 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
-{% block title %}Create {{ component_type }} ({{ device }}){% endblock %}
+{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
 
 
-{% block content %}
+{% block content %}{{ form.errors }}
 <form action="." method="post" class="form form-horizontal">
 <form action="." method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
     <div class="row">
     <div class="row">
@@ -18,13 +18,13 @@
             {% endif %}
             {% endif %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">
-                    <strong>{{ component_type }}</strong>
+                    <strong>{{ component_type|title }}</strong>
                 </div>
                 </div>
                 <div class="panel-body">
                 <div class="panel-body">
                     <div class="form-group">
                     <div class="form-group">
                         <label class="col-md-3 control-label required">Device</label>
                         <label class="col-md-3 control-label required">Device</label>
                         <div class="col-md-9">
                         <div class="col-md-9">
-                            <p class="form-control-static">{{ device }}</p>
+                            <p class="form-control-static">{{ parent }}</p>
                         </div>
                         </div>
                     </div>
                     </div>
                     {% render_form form %}
                     {% render_form form %}

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

@@ -57,7 +57,7 @@
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.platform %}
             {% render_field form.platform %}
             {% render_field form.status %}
             {% render_field form.status %}
-            {% if obj %}
+            {% if obj.pk %}
                 {% render_field form.primary_ip4 %}
                 {% render_field form.primary_ip4 %}
                 {% render_field form.primary_ip6 %}
                 {% render_field form.primary_ip6 %}
             {% endif %}
             {% endif %}

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

@@ -10,7 +10,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Chassis</strong>
                 <strong>Chassis</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Model</td>
                     <td>Model</td>
                     <td>{{ device.device_type.full_name }}</td>
                     <td>{{ device.device_type.full_name }}</td>
@@ -127,7 +127,7 @@
             </table>
             </table>
         </div>
         </div>
         {% if perms.dcim.add_module %}
         {% if perms.dcim.add_module %}
-            <a href="{% url 'dcim:module_add' pk=device.pk %}" class="btn btn-success">
+            <a href="{% url 'dcim:module_add' device=device.pk %}" class="btn btn-success">
                 <span class="fa fa-plus" aria-hidden="true"></span>
                 <span class="fa fa-plus" aria-hidden="true"></span>
                 Add a Module
                 Add a Module
             </a>
             </a>

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

@@ -23,7 +23,7 @@
                 <tr id="{{ iface }}">
                 <tr id="{{ iface }}">
                     <td>{{ iface }}</td>
                     <td>{{ iface }}</td>
                     {% if iface.connection %}
                     {% if iface.connection %}
-                        {% with iface.get_connected_interface as connected_iface %}
+                        {% with iface.connected_interface as connected_iface %}
                             <td class="configured_device" data="{{ connected_iface.device }}">
                             <td class="configured_device" data="{{ connected_iface.device }}">
                                 <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
                                 <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
                             </td>
                             </td>

+ 16 - 1
netbox/templates/dcim/devicetype.html

@@ -39,7 +39,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Chassis</strong>
                 <strong>Chassis</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Manufacturer</td>
                     <td>Manufacturer</td>
                     <td><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></td>
                     <td><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></td>
@@ -145,6 +145,21 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
+        {% with devicetype.get_custom_fields as custom_fields %}
+            {% include 'inc/custom_fields_panel.html' %}
+        {% endwith %}
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Comments</strong>
+            </div>
+            <div class="panel-body">
+                {% if devicetype.comments %}
+                    {{ devicetype.comments|gfm }}
+                {% else %}
+                    <span class="text-muted">None</span>
+                {% endif %}
+            </div>
+        </div>
         {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}

+ 2 - 2
netbox/templates/dcim/component_template_add.html → netbox/templates/dcim/devicetype_component_add.html

@@ -1,7 +1,7 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
-{% block title %}Add {{ component_type }} to {{ devicetype }}{% endblock %}
+{% block title %}Add {{ component_type }} to {{ parent }}{% endblock %}
 
 
 {% block content %}
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
 <form action="." method="post" class="form form-horizontal">
@@ -24,7 +24,7 @@
                     <div class="form-group">
                     <div class="form-group">
                         <label class="col-md-3 control-label required">Device Type</label>
                         <label class="col-md-3 control-label required">Device Type</label>
                         <div class="col-md-9">
                         <div class="col-md-9">
-                            <p class="form-control-static">{{ devicetype }}</p>
+                            <p class="form-control-static">{{ parent }}</p>
                         </div>
                         </div>
                     </div>
                     </div>
                     {% render_form form %}
                     {% render_form form %}

+ 34 - 0
netbox/templates/dcim/devicetype_edit.html

@@ -0,0 +1,34 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Device Type</strong></div>
+        <div class="panel-body">
+            {% render_field form.manufacturer %}
+            {% render_field form.model %}
+            {% render_field form.slug %}
+            {% render_field form.part_number %}
+            {% render_field form.u_height %}
+            {% render_field form.is_full_depth %}
+            {% render_field form.is_console_server %}
+            {% render_field form.is_pdu %}
+            {% render_field form.is_network_device %}
+            {% render_field form.subdevice_role %}
+        </div>
+    </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Comments</strong></div>
+        <div class="panel-body">
+            {% render_field form.comments %}
+        </div>
+    </div>
+{% endblock %}

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

@@ -18,6 +18,7 @@
         {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
         {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
 		{% include 'inc/filter_panel.html' %}
     </div>
     </div>
 </div>
 </div>

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

@@ -1,5 +1,5 @@
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
     {% if device.rack %}
     {% if device.rack %}
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li>
             <li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li>
@@ -13,7 +13,7 @@
         </ol>
         </ol>
     {% endif %}
     {% endif %}
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'dcim:device_list' %}" method="get">
         <form action="{% url 'dcim:device_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Search devices" />
                 <input type="text" name="q" class="form-control" placeholder="Search devices" />
@@ -41,6 +41,7 @@
 {% endif %}
 {% endif %}
 </div>
 </div>
 <h1>{{ device }}</h1>
 <h1>{{ device }}</h1>
+{% include 'inc/created_updated.html' with obj=device %}
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
     <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
     <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
     <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
     <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>

+ 20 - 11
netbox/templates/dcim/inc/_interface.html

@@ -14,9 +14,9 @@
         <small>{{ iface.mac_address|default:'' }}</small>
         <small>{{ iface.mac_address|default:'' }}</small>
     </td>
     </td>
     {% if not iface.is_physical %}
     {% if not iface.is_physical %}
-        <td colspan="2">Virtual</td>
+        <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.connection %}
     {% elif iface.connection %}
-        {% with iface.get_connected_interface as connected_iface %}
+        {% with iface.connected_interface as connected_iface %}
             <td>
             <td>
                 <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
                 <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
             </td>
             </td>
@@ -24,10 +24,16 @@
                 <span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
                 <span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
             </td>
             </td>
         {% endwith %}
         {% endwith %}
-    {% elif iface.circuit %}
-        <td colspan="2">
-            <a href="{% url 'circuits:circuit' pk=iface.circuit.pk %}">{{ iface.circuit }}</a>
-        </td>
+    {% elif iface.circuit_termination %}
+        {% with iface.circuit_termination.get_peer_termination as peer_termination %}
+            <td colspan="2">
+                <i class="fa fa-fw fa-globe" title="Circuit"></i>
+                {% if peer_termination %}
+                    <a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a> via
+                {% endif %}
+                <a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a>
+            </td>
+        {% endwith %}
     {% else %}
     {% else %}
         <td colspan="2">
         <td colspan="2">
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
@@ -35,7 +41,7 @@
     {% endif %}
     {% endif %}
     <td class="text-right">
     <td class="text-right">
         {% if show_graphs %}
         {% if show_graphs %}
-            {% if iface.circuit or iface.connection %}
+            {% if iface.circuit_termination 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">
                 <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>
                     <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
                 </button>
                 </button>
@@ -56,12 +62,15 @@
                     <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
                     <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                     </a>
                     </a>
-                {% elif iface.circuit and perms.circuits.change_circuit %}
-                    <a href="{% url 'circuits:circuit_edit' pk=iface.circuit.pk %}" class="btn btn-danger btn-xs" title="Edit circuit">
+                {% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
+                    <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
+                        <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
+                    </button>
+                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                     </a>
                     </a>
                 {% else %}
                 {% else %}
-                    <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
+                    <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
                         <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
                         <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
                     </a>
                     </a>
                 {% endif %}
                 {% endif %}
@@ -71,7 +80,7 @@
             </a>
             </a>
         {% endif %}
         {% endif %}
         {% if perms.dcim.delete_interface %}
         {% if perms.dcim.delete_interface %}
-            {% if iface.connection or iface.circuit %}
+            {% if iface.connection or iface.circuit_termination %}
                 <button class="btn btn-danger btn-xs" disabled="disabled">
                 <button class="btn btn-danger btn-xs" disabled="disabled">
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>

+ 26 - 0
netbox/templates/dcim/inc/_service.html

@@ -0,0 +1,26 @@
+<tr>
+    <td>{{ service.name }}</td>
+    <td>
+        {{ service.get_protocol_display }}/{{ service.port }}
+    </td>
+    <td>
+        {% for ip in service.ipaddresses.all %}
+            <span>{{ ip.address.ip }}</span><br />
+        {% empty %}
+            <span class="text-muted">All IPs</span>
+        {% endfor %}
+    </td>
+    <td>{{ service.description }}</td>
+    <td class="text-right">
+        {% if perms.ipam.change_service %}
+            <a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
+                <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+        {% if perms.ipam.delete_service %}
+            <a href="{% url 'ipam:service_delete' pk=service.pk %}" class="btn btn-danger btn-xs">
+                <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
+            </a>
+        {% endif %}
+    </td>
+</tr>

+ 14 - 4
netbox/templates/dcim/inc/device_table.html

@@ -1,9 +1,19 @@
 {% extends 'utilities/obj_table.html' %}
 {% extends 'utilities/obj_table.html' %}
 
 
 {% block extra_actions %}
 {% block extra_actions %}
-    {% if perms.dcim.add_interface %}
-        <button type="submit" name="_edit" formaction="{% url 'dcim:device_bulk_add_interface' %}" class="btn btn-primary btn-sm">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces
-        </button>
+    {% if perms.dcim.change_device %}
+        <div class="btn-group">
+            <button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
+            </button>
+            <ul class="dropdown-menu">
+                {% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}" class="formaction">Console Ports</a></li>{% endif %}
+                {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}" class="formaction">Console Server Ports</a></li>{% endif %}
+                {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}" class="formaction">Power Ports</a></li>{% endif %}
+                {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}" class="formaction">Power Outlets</a></li>{% endif %}
+                {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}" class="formaction">Device Bays</a></li>{% endif %}
+            </ul>
+        </div>
     {% endif %}
     {% endif %}
 {% endblock %}
 {% endblock %}

+ 1 - 1
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -6,7 +6,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>{{ title }}</strong>
                 <strong>{{ title }}</strong>
                 <div class="pull-right">
                 <div class="pull-right">
-                    {% if table.rows|length > 3 %}
+                    {% if table.rows|length > 1 %}
                         <button class="btn btn-default btn-xs toggle">
                         <button class="btn btn-default btn-xs toggle">
                             <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
                             <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
                         </button>
                         </button>

+ 9 - 2
netbox/templates/dcim/interfaceconnection_edit.html

@@ -27,6 +27,12 @@
                 <strong>A Side</strong>
                 <strong>A Side</strong>
             </div>
             </div>
             <div class="panel-body">
             <div class="panel-body">
+                <div class="form-group">
+                    <label class="col-md-3 control-label required">Site</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">{{ device.rack.site }}</p>
+                    </div>
+                </div>
                 <div class="form-group">
                 <div class="form-group">
                     <label class="col-md-3 control-label required">Rack</label>
                     <label class="col-md-3 control-label required">Rack</label>
                     <div class="col-md-9">
                     <div class="col-md-9">
@@ -61,6 +67,7 @@
                         {% render_field form.livesearch %}
                         {% render_field form.livesearch %}
                     </div>
                     </div>
                     <div class="tab-pane" id="select">
                     <div class="tab-pane" id="select">
+                        {% render_field form.site_b %}
                         {% render_field form.rack_b %}
                         {% render_field form.rack_b %}
                         {% render_field form.device_b %}
                         {% render_field form.device_b %}
                     </div>
                     </div>
@@ -77,8 +84,8 @@
 </div>
 </div>
 <div class="text-center">
 <div class="text-center">
     <div class="form-group">
     <div class="form-group">
-        <button type="submit" name="_create" class="btn btn-primary">Create</button>
-        <button type="submit" name="_addanother" class="btn btn-primary">Create and Connect Another</button>
+        <button type="submit" name="_create" class="btn btn-primary">Connect</button>
+        <button type="submit" name="_addanother" class="btn btn-primary">Connect and Add Another</button>
         <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
         <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
     </div>
     </div>
 </div>
 </div>

+ 4 - 4
netbox/templates/dcim/rack.html

@@ -6,14 +6,14 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'dcim:rack_list' %}">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><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
             <li>{{ rack }}</li>
             <li>{{ rack }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'dcim:rack_list' %}" method="get">
         <form action="{% url 'dcim:rack_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Search racks" />
                 <input type="text" name="q" class="form-control" placeholder="Search racks" />
@@ -53,13 +53,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>Rack {{ rack.name }}</h1>
 <h1>Rack {{ rack.name }}</h1>
+{% include 'inc/created_updated.html' with obj=rack %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Rack</strong>
                 <strong>Rack</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Site</td>
                     <td>Site</td>
                     <td>
                     <td>
@@ -188,7 +189,6 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
-        {% include 'inc/created_updated.html' with obj=rack %}
 	</div>
 	</div>
     <div class="row col-md-6">
     <div class="row col-md-6">
        <div class="col-md-6 col-sm-6 col-xs-12">
        <div class="col-md-6 col-sm-6 col-xs-12">

+ 34 - 4
netbox/templates/dcim/site.html

@@ -7,13 +7,13 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
             <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
             <li>{{ site }}</li>
             <li>{{ site }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'dcim:site_list' %}" method="get">
         <form action="{% url 'dcim:site_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Search sites" />
                 <input type="text" name="q" class="form-control" placeholder="Search sites" />
@@ -47,13 +47,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ site.name }}</h1>
 <h1>{{ site.name }}</h1>
+{% include 'inc/created_updated.html' with obj=site %}
 <div class="row">
 <div class="row">
 	<div class="col-md-7">
 	<div class="col-md-7">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Site</strong>
                 <strong>Site</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Tenant</td>
                     <td>Tenant</td>
                     <td>
                     <td>
@@ -109,6 +110,36 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Contact Name</td>
+                    <td>
+                        {% if site.contact_name %}
+                            <span>{{ site.contact_name }}</span>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Contact Phone</td>
+                    <td>
+                        {% if site.contact_phone %}
+                            <a href="tel:{{ site.contact_phone }}">{{ site.contact_phone }}</a>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Contact E-Mail</td>
+                    <td>
+                        {% if site.contact_email %}
+                            <a href="mailto:{{ site.contact_email }}">{{ site.contact_email }}</a>
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         {% with site.get_custom_fields as custom_fields %}
         {% with site.get_custom_fields as custom_fields %}
@@ -126,7 +157,6 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
-        {% include 'inc/created_updated.html' with obj=site %}
     </div>
     </div>
     <div class="col-md-5">
     <div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">

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

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

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

@@ -53,10 +53,25 @@
 					<td>Autonomous system number (optional)</td>
 					<td>Autonomous system number (optional)</td>
 					<td>65000</td>
 					<td>65000</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Contact Name</td>
+					<td>Name of administrative contact (optional)</td>
+					<td>Hank Hill</td>
+				</tr>
+				<tr>
+					<td>Contact Phone</td>
+					<td>Phone number (optional)</td>
+					<td>+1-214-555-1234</td>
+				</tr>
+				<tr>
+					<td>Contact E-mail</td>
+					<td>E-mail address (optional)</td>
+					<td>hhill@example.com</td>
+				</tr>
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000</pre>
+		<pre>ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 7 - 7
netbox/templates/home.html

@@ -3,7 +3,7 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row home-search" style="padding: 15px 0px 20px">
 <div class="row home-search" style="padding: 15px 0px 20px">
-	<div class="col-md-3">
+	<div class="col-sm-6 col-md-3">
 		<form action="{% url 'dcim:device_list' %}" method="get">
 		<form action="{% url 'dcim:device_list' %}" method="get">
 			<div class="input-group">
 			<div class="input-group">
 				<input type="text" name="q" placeholder="Search devices" class="form-control" />
 				<input type="text" name="q" placeholder="Search devices" class="form-control" />
@@ -17,7 +17,7 @@
 		</form>
 		</form>
 		<p></p>
 		<p></p>
 	</div>
 	</div>
-	<div class="col-md-3">
+	<div class="col-sm-6 col-md-3">
 		<form action="{% url 'ipam:prefix_list' %}" method="get">
 		<form action="{% url 'ipam:prefix_list' %}" method="get">
 			<div class="input-group">
 			<div class="input-group">
 				<input type="text" name="q" placeholder="Search prefixes" class="form-control" />
 				<input type="text" name="q" placeholder="Search prefixes" class="form-control" />
@@ -31,7 +31,7 @@
 		</form>
 		</form>
 		<p></p>
 		<p></p>
 	</div>
 	</div>
-	<div class="col-md-3">
+	<div class="col-sm-6 col-md-3">
 		<form action="{% url 'ipam:ipaddress_list' %}" method="get">
 		<form action="{% url 'ipam:ipaddress_list' %}" method="get">
 			<div class="input-group">
 			<div class="input-group">
 				<input type="text" name="q" placeholder="Search IPs" class="form-control" />
 				<input type="text" name="q" placeholder="Search IPs" class="form-control" />
@@ -45,7 +45,7 @@
 		</form>
 		</form>
 		<p></p>
 		<p></p>
 	</div>
 	</div>
-	<div class="col-md-3">
+	<div class="col-sm-6 col-md-3">
 		<form action="{% url 'circuits:circuit_list' %}" method="get">
 		<form action="{% url 'circuits:circuit_list' %}" method="get">
 			<div class="input-group">
 			<div class="input-group">
 				<input type="text" name="q" placeholder="Search circuits" class="form-control" />
 				<input type="text" name="q" placeholder="Search circuits" class="form-control" />
@@ -61,7 +61,7 @@
 	</div>
 	</div>
 </div>
 </div>
 <div class="row">
 <div class="row">
-    <div class="col-md-4">
+    <div class="col-sm-6 col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Organization</strong>
                 <strong>Organization</strong>
@@ -106,7 +106,7 @@
             </div>
             </div>
         </div>
         </div>
     </div>
     </div>
-    <div class="col-md-4">
+    <div class="col-sm-6 col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>IPAM</strong>
                 <strong>IPAM</strong>
@@ -157,7 +157,7 @@
             </div>
             </div>
         </div>
         </div>
     </div>
     </div>
-    <div class="col-md-4">
+    <div class="col-sm-6 col-md-4">
         {% if perms.secrets %}
         {% if perms.secrets %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">

+ 1 - 1
netbox/templates/inc/custom_fields_panel.html

@@ -3,7 +3,7 @@
         <div class="panel-heading">
         <div class="panel-heading">
             <strong>Custom Fields</strong>
             <strong>Custom Fields</strong>
         </div>
         </div>
-        <table class="table table-hover panel-body">
+        <table class="table table-hover panel-body attr-table">
             {% for field, value in custom_fields.items %}
             {% for field, value in custom_fields.items %}
                 <tr>
                 <tr>
                     <td>{{ field }}</td>
                     <td>{{ field }}</td>

+ 4 - 4
netbox/templates/ipam/aggregate.html

@@ -5,14 +5,14 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></li>
             <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><a href="{% url 'ipam:aggregate_list' %}?rir={{ aggregate.rir.slug }}">{{ aggregate.rir }}</a></li>
             <li>{{ aggregate }}</li>
             <li>{{ aggregate }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'ipam:aggregate_list' %}" method="get">
         <form action="{% url 'ipam:aggregate_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Search aggregates" />
                 <input type="text" name="q" class="form-control" placeholder="Search aggregates" />
@@ -40,13 +40,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ aggregate }}</h1>
 <h1>{{ aggregate }}</h1>
+{% include 'inc/created_updated.html' with obj=aggregate %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Aggregate</strong>
                 <strong>Aggregate</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Family</td>
                     <td>Family</td>
                     <td>{{ aggregate.get_family_display }}</td>
                     <td>{{ aggregate.get_family_display }}</td>
@@ -79,7 +80,6 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
-        {% include 'inc/created_updated.html' with obj=aggregate %}
     </div>
     </div>
     <div class="col-md-6">
     <div class="col-md-6">
         {% with aggregate.get_custom_fields as custom_fields %}
         {% with aggregate.get_custom_fields as custom_fields %}

+ 4 - 0
netbox/templates/ipam/inc/ipadress_edit_header.html

@@ -0,0 +1,4 @@
+<ul class="nav nav-tabs" style="margin-bottom: 20px">
+    <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}><a href="{% url 'ipam:ipaddress_add' %}">Individual</a></li>
+    <li role="presentation"{% if active_tab == 'bulk_add' %} class="active"{% endif %}><a href="{% url 'ipam:ipaddress_bulk_add' %}">Bulk</a></li>
+</ul>

+ 3 - 2
netbox/templates/ipam/inc/prefix_header.html

@@ -1,5 +1,5 @@
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
             <li><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></li>
             {% if prefix.vrf %}
             {% if prefix.vrf %}
@@ -8,7 +8,7 @@
             <li>{{ prefix }}</li>
             <li>{{ prefix }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'ipam:prefix_list' %}" method="get">
         <form action="{% url 'ipam:prefix_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Search prefixes" />
                 <input type="text" name="q" class="form-control" placeholder="Search prefixes" />
@@ -42,6 +42,7 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ prefix }}</h1>
 <h1>{{ prefix }}</h1>
+{% include 'inc/created_updated.html' with obj=prefix %}
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
     <li role="presentation"{% if active_tab == 'prefix' %} class="active"{% endif %}><a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a></li>
     <li role="presentation"{% if active_tab == 'prefix' %} class="active"{% endif %}><a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a></li>
     <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses</a></li>
     <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses</a></li>

+ 4 - 4
netbox/templates/ipam/ipaddress.html

@@ -5,7 +5,7 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
             <li><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></li>
             {% if ipaddress.vrf %}
             {% if ipaddress.vrf %}
@@ -14,7 +14,7 @@
             <li>{{ ipaddress }}</li>
             <li>{{ ipaddress }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'ipam:ipaddress_list' %}" method="get">
         <form action="{% url 'ipam:ipaddress_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Search IPs" />
                 <input type="text" name="q" class="form-control" placeholder="Search IPs" />
@@ -42,13 +42,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ ipaddress }}</h1>
 <h1>{{ ipaddress }}</h1>
+{% include 'inc/created_updated.html' with obj=ipaddress %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>IP Address</strong>
                 <strong>IP Address</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Family</td>
                     <td>Family</td>
                     <td>{{ ipaddress.get_family_display }}</td>
                     <td>{{ ipaddress.get_family_display }}</td>
@@ -136,7 +137,6 @@
         {% with ipaddress.get_custom_fields as custom_fields %}
         {% with ipaddress.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% endwith %}
-        {% include 'inc/created_updated.html' with obj=ipaddress %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         {% with heading='Parent Prefixes' %}
         {% with heading='Parent Prefixes' %}

+ 22 - 0
netbox/templates/ipam/ipaddress_bulk_add.html

@@ -0,0 +1,22 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load static from staticfiles %}
+{% load form_helpers %}
+
+{% block title %}Bulk Add IP Addresses{% endblock %}
+
+{% block tabs %}
+    {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='bulk_add' %}
+{% endblock %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>IP Address</strong></div>
+        <div class="panel-body">
+            {% render_field form.address %}
+            {% render_field form.vrf %}
+            {% render_field form.tenant %}
+            {% render_field form.status %}
+            {% render_field form.description %}
+        </div>
+    </div>
+{% endblock %}

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

@@ -2,6 +2,12 @@
 {% load static from staticfiles %}
 {% load static from staticfiles %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
+{% block tabs %}
+    {% if not obj.pk %}
+        {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='add' %}
+    {% endif %}
+{% endblock %}
+
 {% block form %}
 {% block form %}
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>IP Address</strong></div>
         <div class="panel-heading"><strong>IP Address</strong></div>
@@ -10,7 +16,7 @@
             {% render_field form.vrf %}
             {% render_field form.vrf %}
             {% render_field form.tenant %}
             {% render_field form.tenant %}
             {% render_field form.status %}
             {% render_field form.status %}
-            {% if obj %}
+            {% if obj.pk %}
                 <div class="form-group">
                 <div class="form-group">
                     <label class="col-md-3 control-label">Device</label>
                     <label class="col-md-3 control-label">Device</label>
                     <div class="col-md-9">
                     <div class="col-md-9">

+ 11 - 2
netbox/templates/ipam/prefix.html

@@ -11,7 +11,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Prefix</strong>
                 <strong>Prefix</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Family</td>
                     <td>Family</td>
                     <td>{{ prefix.get_family_display }}</td>
                     <td>{{ prefix.get_family_display }}</td>
@@ -85,6 +85,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Is a pool</td>
+                    <td>
+                        {% if prefix.is_pool %}
+                            <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
+                        {% else %}
+                            <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Description</td>
                     <td>Description</td>
                     <td>
                     <td>
@@ -104,7 +114,6 @@
         {% with prefix.get_custom_fields as custom_fields %}
         {% with prefix.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% endwith %}
-        {% include 'inc/created_updated.html' with obj=prefix %}
         <br />
         <br />
 	</div>
 	</div>
 	<div class="col-md-7">
 	<div class="col-md-7">

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

@@ -12,6 +12,7 @@
             {% render_field form.vlan %}
             {% render_field form.vlan %}
             {% render_field form.status %}
             {% render_field form.status %}
             {% render_field form.role %}
             {% render_field form.role %}
+            {% render_field form.is_pool %}
             {% render_field form.description %}
             {% render_field form.description %}
         </div>
         </div>
     </div>
     </div>

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

@@ -68,6 +68,11 @@
 					<td>Functional role (optional)</td>
 					<td>Functional role (optional)</td>
 					<td>Customer</td>
 					<td>Customer</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Is a pool</td>
+					<td>True if all IPs are considered usable</td>
+					<td>False</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Description</td>
 					<td>Description</td>
 					<td>Short description (optional)</td>
 					<td>Short description (optional)</td>
@@ -76,7 +81,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,7th floor WiFi</pre>
+		<pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,False,7th floor WiFi</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 26 - 0
netbox/templates/ipam/service_edit.html

@@ -0,0 +1,26 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Service</strong></div>
+        <div class="panel-body">
+            <div class="form-group">
+                <label class="col-md-3 control-label">Device</label>
+                <div class="col-md-9">
+                    <p class="form-control-static">{{ obj.device }}</p>
+                </div>
+            </div>
+            {% render_field form.name %}
+            <div class="form-group form-inline">
+                <label class="col-md-3 control-label required">Port</label>
+                <div class="col-md-9">
+                    {{ form.protocol }}
+                    {{ form.port }}
+                </div>
+            </div>
+            {% render_field form.ipaddresses %}
+            {% render_field form.description %}
+        </div>
+    </div>
+{% endblock %}

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

@@ -5,7 +5,7 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:vlan_list' %}">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>
             <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
@@ -15,7 +15,7 @@
             <li>{{ vlan.name }} ({{ vlan.vid }})</li>
             <li>{{ vlan.name }} ({{ vlan.vid }})</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
     <form action="{% url 'ipam:vlan_list' %}" method="get">
     <form action="{% url 'ipam:vlan_list' %}" method="get">
         <div class="input-group">
         <div class="input-group">
             <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
             <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
@@ -43,13 +43,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>VLAN {{ vlan.display_name }}</h1>
 <h1>VLAN {{ vlan.display_name }}</h1>
+{% include 'inc/created_updated.html' with obj=vlan %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>VLAN</strong>
                 <strong>VLAN</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Site</td>
                     <td>Site</td>
                     <td><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></td>
                     <td><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></td>
@@ -113,7 +114,6 @@
         {% with vlan.get_custom_fields as custom_fields %}
         {% with vlan.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% endwith %}
-        {% include 'inc/created_updated.html' with obj=vlan %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 4 - 4
netbox/templates/ipam/vrf.html

@@ -5,13 +5,13 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'ipam:vrf_list' %}">VRFs</a></li>
             <li><a href="{% url 'ipam:vrf_list' %}">VRFs</a></li>
             <li>{{ vrf }}</li>
             <li>{{ vrf }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'ipam:vrf_list' %}" method="get">
         <form action="{% url 'ipam:vrf_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Search VRFs" />
                 <input type="text" name="q" class="form-control" placeholder="Search VRFs" />
@@ -39,13 +39,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ vrf }}</h1>
 <h1>{{ vrf }}</h1>
+{% include 'inc/created_updated.html' with obj=vrf %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>VRF</strong>
                 <strong>VRF</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Route Distinguisher</td>
                     <td>Route Distinguisher</td>
                     <td>{{ vrf.rd }}</td>
                     <td>{{ vrf.rd }}</td>
@@ -85,7 +86,6 @@
         {% with vrf.get_custom_fields as custom_fields %}
         {% with vrf.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% endwith %}
-        {% include 'inc/created_updated.html' with obj=vrf %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 5 - 6
netbox/templates/paginator.html

@@ -2,11 +2,10 @@
 
 
 {# Custom pagination controls to render nicely with Bootstrap CSS. smart_pages requires EnhancedPaginator. #}
 {# Custom pagination controls to render nicely with Bootstrap CSS. smart_pages requires EnhancedPaginator. #}
 
 
-<div class="row">
-    <div class="col-md-7">
+<div class="paginator pull-right">
     {% if table.paginator.num_pages > 1 %}
     {% if table.paginator.num_pages > 1 %}
         <nav>
         <nav>
-            <ul class="pagination">
+            <ul class="pagination pull-right">
                 {% if table.page.has_previous %}
                 {% if table.page.has_previous %}
                     <li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">&laquo;</a></li>
                     <li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">&laquo;</a></li>
                 {% endif %}
                 {% endif %}
@@ -23,8 +22,8 @@
             </ul>
             </ul>
         </nav>
         </nav>
     {% endif %}
     {% endif %}
-    </div>
-    <div class="col-md-5 text-right text-muted">
+    <div class="clearfix"></div>
+    <div class="text-right text-muted">
         Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
         Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
         {% if total == 1 %}
         {% if total == 1 %}
             {{ table.data.verbose_name }}
             {{ table.data.verbose_name }}
@@ -32,4 +31,4 @@
             {{ table.data.verbose_name_plural }}
             {{ table.data.verbose_name_plural }}
         {% endif %}
         {% endif %}
     </div>
     </div>
-</div>
+</div>

+ 1 - 1
netbox/templates/secrets/secret.html

@@ -29,6 +29,7 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ secret }}</h1>
 <h1>{{ secret }}</h1>
+{% include 'inc/created_updated.html' with obj=secret %}
 <div class="row">
 <div class="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -58,7 +59,6 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
-        {% include 'inc/created_updated.html' with obj=secret %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
         {% if secret|decryptable_by:request.user %}
         {% if secret|decryptable_by:request.user %}

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

@@ -5,7 +5,7 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
-    <div class="col-md-9">
+    <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></li>
             <li><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></li>
             {% if tenant.group %}
             {% if tenant.group %}
@@ -14,7 +14,7 @@
             <li>{{ tenant }}</li>
             <li>{{ tenant }}</li>
         </ol>
         </ol>
     </div>
     </div>
-    <div class="col-md-3">
+    <div class="col-sm-4 col-md-3">
         <form action="{% url 'tenancy:tenant_list' %}" method="get">
         <form action="{% url 'tenancy:tenant_list' %}" method="get">
             <div class="input-group">
             <div class="input-group">
                 <input type="text" name="q" class="form-control" placeholder="Name" />
                 <input type="text" name="q" class="form-control" placeholder="Name" />
@@ -42,13 +42,14 @@
     {% endif %}
     {% endif %}
 </div>
 </div>
 <h1>{{ tenant }}</h1>
 <h1>{{ tenant }}</h1>
+{% include 'inc/created_updated.html' with obj=tenant %}
 <div class="row">
 <div class="row">
 	<div class="col-md-7">
 	<div class="col-md-7">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Tenant</strong>
                 <strong>Tenant</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
                     <td>Group</td>
                     <td>Group</td>
                     <td>
                     <td>
@@ -86,7 +87,6 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
-        {% include 'inc/created_updated.html' with obj=tenant %}
 	</div>
 	</div>
 	<div class="col-md-5">
 	<div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 22 - 0
netbox/templates/users/_user.html

@@ -0,0 +1,22 @@
+{% extends '_base.html' %}
+
+{% block content %}
+<div class="row">
+    <div class="col-sm-12 col-md-8 col-md-offset-2">
+        <h1>{% block title %}{% endblock %}</h1>
+    </div>
+</div>
+<div class="row">
+    <div class="col-sm-3 col-md-2 col-md-offset-2">
+        <ul class="nav nav-pills nav-stacked">
+            <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}><a href="{% url 'users:profile' %}">Profile</a></li>
+            <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}><a href="{% url 'users:change_password' %}">Change Password</a></li>
+            <li{% ifequal active_tab "userkey" %} class="active"{% endifequal %}><a href="{% url 'users:userkey' %}">User Key</a></li>
+            <li{% ifequal active_tab "recent_activity" %} class="active"{% endifequal %}><a href="{% url 'users:recent_activity' %}">Recent Activity</a></li>
+        </ul>
+    </div>
+	<div class="col-sm-9 col-md-6">
+        {% block usercontent %}{% endblock %}
+	</div>
+</div>
+{% endblock %}

+ 21 - 37
netbox/templates/users/change_password.html

@@ -1,46 +1,30 @@
-{% extends '_base.html' %}
+{% extends 'users/_user.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Change Password{% endblock %}
 {% block title %}Change Password{% endblock %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-md-8 col-md-offset-2">
-        <h1>Change Password</h1>
-    </div>
-</div>
-<div class="row">
-    <div class="col-md-2 col-md-offset-2">
-        {% include 'users/inc/profile_nav.html' with active_tab="change_password" %}
-    </div>
-	<div class="col-md-6">
-        <form action="." method="post" class="form form-horizontal">
-            {% csrf_token %}
-            {% if form.non_field_errors %}
-                <div class="panel panel-danger">
-                    <div class="panel-heading"><strong>Errors</strong></div>
-                    <div class="panel-body">
-                        {{ form.non_field_errors }}
-                    </div>
-                </div>
-            {% endif %}
-            <div class="panel panel-default">
-                <div class="panel-heading"><strong>Password</strong></div>
+{% block usercontent %}
+    <form action="." method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {% if form.non_field_errors %}
+            <div class="panel panel-danger">
+                <div class="panel-heading"><strong>Errors</strong></div>
                 <div class="panel-body">
                 <div class="panel-body">
-                    {% render_field form.old_password %}
-                    {% render_field form.new_password1 %}
-                    {% render_field form.new_password2 %}
+                    {{ form.non_field_errors }}
                 </div>
                 </div>
             </div>
             </div>
-            <div class="row">
-                <div class="form-group">
-                    <div class="col-md-12 text-center">
-                        <button type="submit" name="_update" class="btn btn-primary">Update</button>
-                        <a href="{% url 'users:profile' %}" class="btn btn-default">Cancel</a>
-                    </div>
-                </div>
+        {% endif %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Password</strong></div>
+            <div class="panel-body">
+                {% render_field form.old_password %}
+                {% render_field form.new_password1 %}
+                {% render_field form.new_password2 %}
             </div>
             </div>
-        </form>
-    </div>
-</div>
+        </div>
+        <div class="text-right">
+            <button type="submit" name="_update" class="btn btn-primary">Update</button>
+            <a href="{% url 'users:profile' %}" class="btn btn-default">Cancel</a>
+        </div>
+    </form>
 {% endblock %}
 {% endblock %}

+ 0 - 6
netbox/templates/users/inc/profile_nav.html

@@ -1,6 +0,0 @@
-<ul class="nav nav-pills nav-stacked">
-    <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}><a href="{% url 'users:profile' %}">Profile</a></li>
-    <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}><a href="{% url 'users:change_password' %}">Change Password</a></li>
-    <li{% ifequal active_tab "userkey" %} class="active"{% endifequal %}><a href="{% url 'users:userkey' %}">User Key</a></li>
-    <li{% ifequal active_tab "recent_activity" %} class="active"{% endifequal %}><a href="{% url 'users:recent_activity' %}">Recent Activity</a></li>
-</ul>

+ 14 - 26
netbox/templates/users/profile.html

@@ -1,31 +1,19 @@
-{% extends '_base.html' %}
+{% extends 'users/_user.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block title %}User Profile{% endblock %}
 {% block title %}User Profile{% endblock %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-md-8 col-md-offset-2">
-        <h1>User Profile</h1>
-    </div>
-</div>
-<div class="row">
-    <div class="col-md-2 col-md-offset-2">
-        {% include 'users/inc/profile_nav.html' with active_tab="profile" %}
-    </div>
-	<div class="col-md-6">
-        <small class="text-muted">User login</small>
-        <h5>{{ request.user.username }}</h5>
-        <small class="text-muted">Full name</small>
-        <h5>{{ request.user.first_name }} {{ request.user.last_name }}</h5>
-        <small class="text-muted">Email</small>
-        <h5>{{ request.user.email }}</h5>
-        <small class="text-muted">Registered</small>
-        <h5>{{ request.user.date_joined }}</h5>
-        <small class="text-muted">Groups</small>
-        <h5>{{ request.user.groups.all|join:', ' }}</h5>
-        <small class="text-muted">Admin access</small>
-        <h5>{{ request.user.is_staff|yesno|capfirst }}</h5>
-	</div>
-</div>
+{% block usercontent %}
+    <small class="text-muted">User login</small>
+    <h5>{{ request.user.username }}</h5>
+    <small class="text-muted">Full name</small>
+    <h5>{{ request.user.first_name }} {{ request.user.last_name }}</h5>
+    <small class="text-muted">Email</small>
+    <h5>{{ request.user.email }}</h5>
+    <small class="text-muted">Registered</small>
+    <h5>{{ request.user.date_joined }}</h5>
+    <small class="text-muted">Groups</small>
+    <h5>{{ request.user.groups.all|join:', ' }}</h5>
+    <small class="text-muted">Admin access</small>
+    <h5>{{ request.user.is_staff|yesno|capfirst }}</h5>
 {% endblock %}
 {% endblock %}

+ 16 - 29
netbox/templates/users/recent_activity.html

@@ -1,35 +1,22 @@
-{% extends '_base.html' %}
-{% load form_helpers %}
+{% extends 'users/_user.html' %}
 
 
 {% block title %}Recent Activity{% endblock %}
 {% block title %}Recent Activity{% endblock %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-md-8 col-md-offset-2">
-        <h1>Recent Activity</h1>
-    </div>
-</div>
-<div class="row">
-    <div class="col-md-2 col-md-offset-2">
-        {% include 'users/inc/profile_nav.html' with active_tab="recent_activity" %}
-    </div>
-	<div class="col-md-6">
-        <table class="table table-hover">
-            <thead>
+{% block usercontent %}
+    <table class="table table-hover">
+        <thead>
+            <tr>
+                <th>Time</th>
+                <th>Action</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for action in recent_activity %}
                 <tr>
                 <tr>
-                    <th>Time</th>
-                    <th>Action</th>
+                    <td>{{ action.time|date:'SHORT_DATETIME_FORMAT' }}</td>
+                    <td>{{ action.icon }} {{ action.message|safe }}</td>
                 </tr>
                 </tr>
-            </thead>
-            <tbody>
-                {% for action in recent_activity %}
-                    <tr>
-                        <td>{{ action.time|date:'SHORT_DATETIME_FORMAT' }}</td>
-                        <td>{{ action.icon }} {{ action.message|safe }}</td>
-                    </tr>
-                {% endfor %}
-            </tbody>
-        </table>
-	</div>
-</div>
+            {% endfor %}
+        </tbody>
+    </table>
 {% endblock %}
 {% endblock %}

+ 29 - 42
netbox/templates/users/userkey.html

@@ -1,46 +1,33 @@
-{% extends '_base.html' %}
-{% load form_helpers %}
+{% extends 'users/_user.html' %}
 
 
 {% block title %}User Key{% endblock %}
 {% block title %}User Key{% endblock %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-md-8 col-md-offset-2">
-        <h1>User Key</h1>
-    </div>
-</div>
-<div class="row">
-    <div class="col-md-2 col-md-offset-2">
-        {% include 'users/inc/profile_nav.html' with active_tab="userkey" %}
-    </div>
-	<div class="col-md-6">
-        {% if userkey %}
-            <h4>
-                Your user key is:
-                {% if userkey.is_active %}
-                    <span class="label label-success">Active</span>
-                {% else %}
-                    <span class="label label-danger">Inactive</span>
-                {% endif %}
-            </h4>
-            <p>Your public key is below.</p>
-            <pre>{{ userkey.public_key }}</pre>
-            <div class="pull-right">
-                <a href="{% url 'users:userkey_edit' %}" class="btn btn-warning">
-                    <span class="fa fa-pencil" aria-hidden="true"></span>
-                    Edit user key
-                </a>
-            </div>
-            {% include 'inc/created_updated.html' with obj=userkey %}
-        {% else %}
-            <p>You don't have a user key on file.</p>
-            <p>
-                <a href="{% url 'users:userkey_edit' %}" class="btn btn-primary">
-                    <span class="fa fa-plus" aria-hidden="true"></span>
-                    Create a User Key
-                </a>
-            </p>
-        {% endif %}
-	</div>
-</div>
+{% block usercontent %}
+    {% if userkey %}
+        <h4>
+            Your user key is:
+            {% if userkey.is_active %}
+                <span class="label label-success">Active</span>
+            {% else %}
+                <span class="label label-danger">Inactive</span>
+            {% endif %}
+        </h4>
+        <p>Your public key is below.</p>
+        <pre>{{ userkey.public_key }}</pre>
+        <div class="pull-right">
+            <a href="{% url 'users:userkey_edit' %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit user key
+            </a>
+        </div>
+        {% include 'inc/created_updated.html' with obj=userkey %}
+    {% else %}
+        <p>You don't have a user key on file.</p>
+        <p>
+            <a href="{% url 'users:userkey_edit' %}" class="btn btn-primary">
+                <span class="fa fa-plus" aria-hidden="true"></span>
+                Create a User Key
+            </a>
+        </p>
+    {% endif %}
 {% endblock %}
 {% endblock %}

+ 41 - 54
netbox/templates/users/userkey_edit.html

@@ -1,71 +1,58 @@
-{% extends '_base.html' %}
+{% extends 'users/_user.html' %}
 {% load static from staticfiles %}
 {% load static from staticfiles %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}User Key{% endblock %}
 {% block title %}User Key{% endblock %}
 
 
-{% block content %}
-<div class="row">
-    <div class="col-md-8 col-md-offset-2">
-        <h1>User Key</h1>
-    </div>
-</div>
-<div class="row">
-    <div class="col-md-2 col-md-offset-2">
-        {% include 'users/inc/profile_nav.html' with active_tab="userkey" %}
-    </div>
-	<div class="col-md-6">
-        {% if userkey.is_active %}
-            <div class="alert alert-danger" role="alert">
-                <strong>Warning:</strong> Changing your public key will require your user key to be re-activated by another
-                user. You will be unable to retrieve any secrets until your key has been reactivated.
-            </div>
-        {% endif %}
-        <form action="." method="post" class="form">
-            {% csrf_token %}
+{% block usercontent %}
+    {% if userkey.is_active %}
+        <div class="alert alert-danger" role="alert">
+            <strong>Warning:</strong> Changing your public key will require your user key to be re-activated by another
+            user. You will be unable to retrieve any secrets until your key has been reactivated.
+        </div>
+    {% endif %}
+    <form action="." method="post" class="form">
+        {% csrf_token %}
+        <div class="form-group">
+            {% render_field form.public_key %}
+        </div>
+        <div class="row">
             <div class="form-group">
             <div class="form-group">
-                {% render_field form.public_key %}
+                <div class="col-sm-6 col-md-6">
+                    <button type="button" class="btn btn-info" id="generate_keypair">Generate a New Key Pair</button>
+                </div>
+                <div class="col-sm-6 col-md-6 text-right">
+                    <button type="submit" name="_update" class="btn btn-primary">Save</button>
+                    <a href="{% url 'users:userkey' %}" class="btn btn-default">Cancel</a>
+                </div>
             </div>
             </div>
-            <div class="row">
-                <div class="form-group">
-                    <div class="col-md-6">
-                        <button type="button" class="btn btn-info" id="generate_keypair">Generate a New Key Pair</button>
+        </div>
+    </form>
+    <div class="modal fade" id="new_keypair_modal" tabindex="-1" role="dialog">
+        <div class="modal-dialog modal-md" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+                    <h4 class="modal-title" id="new_keypair_modal_title">
+                        New RSA Key Pair
+                    </h4>
+                </div>
+                <div class="modal-body">
+                    <strong>New Public Key</strong>
+                    <div class="form-group">
+                        <textarea class="form-control" id="new_pubkey" style="height: 250px;"></textarea>
                     </div>
                     </div>
-                    <div class="col-md-6 text-right">
-                        <button type="submit" name="_update" class="btn btn-primary">Save</button>
-                        <a href="{% url 'users:userkey' %}" class="btn btn-default">Cancel</a>
+                    <strong>New Private Key</strong>
+                    <div class="form-group">
+                        <textarea class="form-control" id="new_privkey" style="height: 250px;"></textarea>
                     </div>
                     </div>
                 </div>
                 </div>
-            </div>
-        </form>
-	</div>
-</div>
-
-<div class="modal fade" id="new_keypair_modal" tabindex="-1" role="dialog">
-    <div class="modal-dialog modal-md" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
-                <h4 class="modal-title" id="new_keypair_modal_title">
-                    New RSA Key Pair
-                </h4>
-            </div>
-            <div class="modal-body">
-                <strong>New Public Key</strong>
-                <div class="form-group">
-                    <textarea class="form-control" id="new_pubkey" style="height: 250px;"></textarea>
-                </div>
-                <strong>New Private Key</strong>
-                <div class="form-group">
-                    <textarea class="form-control" id="new_privkey" style="height: 250px;"></textarea>
+                <div class="modal-footer text-center">
+                    <button type="button" class="btn btn-danger" id="use_new_pubkey" data-dismiss="modal">I have saved my new private key</button>
                 </div>
                 </div>
             </div>
             </div>
-            <div class="modal-footer text-center">
-                <button type="button" class="btn btn-danger" id="use_new_pubkey" data-dismiss="modal">I have saved my new private key</button>
-            </div>
         </div>
         </div>
     </div>
     </div>
-</div>
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}

+ 2 - 2
netbox/templates/utilities/confirmation_form.html

@@ -6,7 +6,7 @@
 	<div class="col-md-6 col-md-offset-3">
 	<div class="col-md-6 col-md-offset-3">
         <form action="." method="post" class="form">
         <form action="." method="post" class="form">
         {% csrf_token %}
         {% csrf_token %}
-            <div class="panel panel-danger">
+            <div class="panel panel-{{ panel_class|default:"danger" }}">
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-body">
                 <div class="panel-body">
                     {% block message %}<p>Are you sure?</p>{% endblock %}
                     {% block message %}<p>Are you sure?</p>{% endblock %}
@@ -22,7 +22,7 @@
                         </div>
                         </div>
                     </div>
                     </div>
                     <div class="text-right">
                     <div class="text-right">
-                        <button type="submit" name="_confirm" class="btn btn-danger">Confirm</button>
+                        <button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
                         <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
                         <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
                     </div>
                     </div>
                 </div>
                 </div>

+ 6 - 6
netbox/templates/utilities/obj_edit.html

@@ -1,16 +1,16 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
-{% block title %}
-    {% if obj %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}
-{% endblock %}
-
 {% block content %}
 {% block content %}
     <form action="." method="post" class="form form-horizontal">
     <form action="." method="post" class="form form-horizontal">
         {% csrf_token %}
         {% csrf_token %}
+        {% for field in form.hidden_fields %}
+            {{ field }}
+        {% endfor %}
         <div class="row">
         <div class="row">
             <div class="col-md-6 col-md-offset-3">
             <div class="col-md-6 col-md-offset-3">
-                <h3>{% if obj %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}</h3>
+                <h3>{% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %}</h3>
+                {% block tabs %}{% endblock %}
                 {% if form.non_field_errors %}
                 {% if form.non_field_errors %}
                     <div class="panel panel-danger">
                     <div class="panel panel-danger">
                         <div class="panel-heading"><strong>Errors</strong></div>
                         <div class="panel-heading"><strong>Errors</strong></div>
@@ -31,7 +31,7 @@
         </div>
         </div>
         <div class="row">
         <div class="row">
             <div class="col-md-6 col-md-offset-3 text-right">
             <div class="col-md-6 col-md-offset-3 text-right">
-                {% if obj %}
+                {% if obj.pk %}
                     <button type="submit" name="_update" class="btn btn-primary">Update</button>
                     <button type="submit" name="_update" class="btn btn-primary">Update</button>
                 {% else %}
                 {% else %}
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>

+ 2 - 1
netbox/templates/utilities/obj_table.html

@@ -4,7 +4,7 @@
     <form method="post" class="form form-horizontal">
     <form method="post" class="form form-horizontal">
         {% csrf_token %}
         {% csrf_token %}
         <input type="hidden" name="redirect_url" value="{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" />
         <input type="hidden" name="redirect_url" value="{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" />
-        <input type="hidden" name="pk_all" value="{% for row in table.rows %}{{ row.record.pk|default:'' }}{% if not forloop.last %},{% endif %}{% endfor %}" />
+        <input type="hidden" name="pk_all" value="{% for obj in table.data.queryset %}{{ obj.pk|default:'' }}{% if not forloop.last %},{% endif %}{% endfor %}" />
         {% if table.paginator.num_pages > 1 %}
         {% if table.paginator.num_pages > 1 %}
             <div id="select_all_box" class="hidden alert alert-info">
             <div id="select_all_box" class="hidden alert alert-info">
                 <div class="checkbox-inline">
                 <div class="checkbox-inline">
@@ -31,3 +31,4 @@
 {% else %}
 {% else %}
     {% render_table table table_template|default:'table.html' %}
     {% render_table table table_template|default:'table.html' %}
 {% endif %}
 {% endif %}
+<div class="clearfix"></div>

+ 2 - 2
netbox/tenancy/forms.py

@@ -11,7 +11,7 @@ from .models import Tenant, TenantGroup
 # Tenant groups
 # Tenant groups
 #
 #
 
 
-class TenantGroupForm(forms.ModelForm, BootstrapMixin):
+class TenantGroupForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -41,7 +41,7 @@ class TenantFromCSVForm(forms.ModelForm):
         fields = ['name', 'slug', 'group', 'description']
         fields = ['name', 'slug', 'group', 'description']
 
 
 
 
-class TenantImportForm(BulkImportForm, BootstrapMixin):
+class TenantImportForm(BootstrapMixin, BulkImportForm):
     csv = CSVDataField(csv_form=TenantFromCSVForm)
     csv = CSVDataField(csv_form=TenantFromCSVForm)
 
 
 
 

+ 2 - 2
netbox/users/forms.py

@@ -3,7 +3,7 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
 
 
 
 
-class LoginForm(AuthenticationForm, BootstrapMixin):
+class LoginForm(BootstrapMixin, AuthenticationForm):
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(LoginForm, self).__init__(*args, **kwargs)
         super(LoginForm, self).__init__(*args, **kwargs)
@@ -12,5 +12,5 @@ class LoginForm(AuthenticationForm, BootstrapMixin):
         self.fields['password'].widget.attrs['placeholder'] = ''
         self.fields['password'].widget.attrs['placeholder'] = ''
 
 
 
 
-class PasswordChangeForm(DjangoPasswordChangeForm, BootstrapMixin):
+class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
     pass
     pass

+ 6 - 1
netbox/users/views.py

@@ -56,6 +56,7 @@ def logout(request):
 def profile(request):
 def profile(request):
 
 
     return render(request, 'users/profile.html', {
     return render(request, 'users/profile.html', {
+        'active_tab': 'profile',
     })
     })
 
 
 
 
@@ -75,6 +76,7 @@ def change_password(request):
 
 
     return render(request, 'users/change_password.html', {
     return render(request, 'users/change_password.html', {
         'form': form,
         'form': form,
+        'active_tab': 'change_password',
     })
     })
 
 
 
 
@@ -88,6 +90,7 @@ def userkey(request):
 
 
     return render(request, 'users/userkey.html', {
     return render(request, 'users/userkey.html', {
         'userkey': userkey,
         'userkey': userkey,
+        'active_tab': 'userkey',
     })
     })
 
 
 
 
@@ -114,6 +117,7 @@ def userkey_edit(request):
     return render(request, 'users/userkey_edit.html', {
     return render(request, 'users/userkey_edit.html', {
         'userkey': userkey,
         'userkey': userkey,
         'form': form,
         'form': form,
+        'active_tab': 'userkey',
     })
     })
 
 
 
 
@@ -121,5 +125,6 @@ def userkey_edit(request):
 def recent_activity(request):
 def recent_activity(request):
 
 
     return render(request, 'users/recent_activity.html', {
     return render(request, 'users/recent_activity.html', {
-        'recent_activity': request.user.actions.all()[:50]
+        'recent_activity': request.user.actions.all()[:50],
+        'active_tab': 'recent_activity',
     })
     })

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini