Kaynağa Gözat

Merge pull request #1230 from digitalocean/develop

Release v2.0.4
Jeremy Stretch 8 yıl önce
ebeveyn
işleme
f7b0d22f86
100 değiştirilmiş dosya ile 1516 ekleme ve 664 silme
  1. 8 0
      CONTRIBUTING.md
  2. 8 0
      docs/installation/upgrading.md
  3. 2 0
      netbox/circuits/api/serializers.py
  4. 2 0
      netbox/circuits/api/urls.py
  5. 3 1
      netbox/circuits/api/views.py
  6. 2 0
      netbox/circuits/apps.py
  7. 2 0
      netbox/circuits/filters.py
  8. 16 14
      netbox/circuits/forms.py
  9. 81 0
      netbox/circuits/migrations/0009_unicode_literals.py
  10. 4 2
      netbox/circuits/models.py
  11. 2 0
      netbox/circuits/signals.py
  12. 2 1
      netbox/circuits/tables.py
  13. 2 0
      netbox/circuits/tests/test_api.py
  14. 4 2
      netbox/circuits/urls.py
  15. 37 28
      netbox/circuits/views.py
  16. 2 0
      netbox/dcim/api/exceptions.py
  17. 4 1
      netbox/dcim/api/serializers.py
  18. 2 0
      netbox/dcim/api/urls.py
  19. 2 0
      netbox/dcim/api/views.py
  20. 2 0
      netbox/dcim/apps.py
  21. 2 0
      netbox/dcim/fields.py
  22. 2 0
      netbox/dcim/filters.py
  23. 2 0
      netbox/dcim/formfields.py
  24. 91 30
      netbox/dcim/forms.py
  25. 209 0
      netbox/dcim/migrations/0037_unicode_literals.py
  26. 13 13
      netbox/dcim/models.py
  27. 2 1
      netbox/dcim/tables.py
  28. 2 0
      netbox/dcim/tests/test_api.py
  29. 3 0
      netbox/dcim/tests/test_forms.py
  30. 3 0
      netbox/dcim/tests/test_models.py
  31. 9 8
      netbox/dcim/urls.py
  32. 248 215
      netbox/dcim/views.py
  33. 2 0
      netbox/extras/admin.py
  34. 7 5
      netbox/extras/api/customfields.py
  35. 3 1
      netbox/extras/api/serializers.py
  36. 2 0
      netbox/extras/api/urls.py
  37. 2 0
      netbox/extras/api/views.py
  38. 2 0
      netbox/extras/filters.py
  39. 2 1
      netbox/extras/forms.py
  40. 2 0
      netbox/extras/management/commands/run_inventory.py
  41. 91 0
      netbox/extras/migrations/0007_unicode_literals.py
  42. 6 5
      netbox/extras/models.py
  43. 4 2
      netbox/extras/rpc.py
  44. 2 0
      netbox/extras/tests/test_api.py
  45. 1 1
      netbox/extras/tests/test_customfields.py
  46. 2 0
      netbox/extras/urls.py
  47. 2 0
      netbox/extras/views.py
  48. 2 0
      netbox/ipam/api/serializers.py
  49. 2 0
      netbox/ipam/api/urls.py
  50. 2 0
      netbox/ipam/api/views.py
  51. 2 0
      netbox/ipam/apps.py
  52. 2 0
      netbox/ipam/fields.py
  53. 2 1
      netbox/ipam/filters.py
  54. 2 0
      netbox/ipam/formfields.py
  55. 45 19
      netbox/ipam/forms.py
  56. 2 0
      netbox/ipam/lookups.py
  57. 133 0
      netbox/ipam/migrations/0016_unicode_literals.py
  58. 5 4
      netbox/ipam/models.py
  59. 2 1
      netbox/ipam/tables.py
  60. 2 1
      netbox/ipam/tests/test_api.py
  61. 3 1
      netbox/ipam/tests/test_models.py
  62. 8 6
      netbox/ipam/urls.py
  63. 215 165
      netbox/ipam/views.py
  64. 2 0
      netbox/netbox/forms.py
  65. 3 2
      netbox/netbox/settings.py
  66. 7 5
      netbox/netbox/urls.py
  67. 38 33
      netbox/netbox/views.py
  68. 1 0
      netbox/netbox/wsgi.py
  69. 6 4
      netbox/secrets/admin.py
  70. 2 0
      netbox/secrets/api/serializers.py
  71. 2 0
      netbox/secrets/api/urls.py
  72. 4 3
      netbox/secrets/api/views.py
  73. 4 2
      netbox/secrets/decorators.py
  74. 3 0
      netbox/secrets/exceptions.py
  75. 2 0
      netbox/secrets/filters.py
  76. 2 1
      netbox/secrets/forms.py
  77. 2 0
      netbox/secrets/hashers.py
  78. 20 0
      netbox/secrets/migrations/0003_unicode_literals.py
  79. 4 3
      netbox/secrets/models.py
  80. 5 3
      netbox/secrets/tables.py
  81. 2 0
      netbox/secrets/templatetags/secret_helpers.py
  82. 2 0
      netbox/secrets/tests/test_api.py
  83. 2 0
      netbox/secrets/tests/test_models.py
  84. 3 1
      netbox/secrets/urls.py
  85. 14 11
      netbox/secrets/views.py
  86. 24 21
      netbox/templates/500.html
  87. 2 17
      netbox/templates/circuits/circuittermination_edit.html
  88. 1 6
      netbox/templates/dcim/consoleport_connect.html
  89. 1 6
      netbox/templates/dcim/consoleserverport_connect.html
  90. 6 1
      netbox/templates/dcim/device_import_child.html
  91. 1 6
      netbox/templates/dcim/poweroutlet_connect.html
  92. 1 6
      netbox/templates/dcim/powerport_connect.html
  93. 1 1
      netbox/templates/inc/table.html
  94. 1 0
      netbox/templates/ipam/ipaddress_edit.html
  95. 2 0
      netbox/tenancy/api/serializers.py
  96. 2 0
      netbox/tenancy/api/urls.py
  97. 3 2
      netbox/tenancy/api/views.py
  98. 2 0
      netbox/tenancy/apps.py
  99. 2 0
      netbox/tenancy/filters.py
  100. 5 1
      netbox/tenancy/forms.py

+ 8 - 0
CONTRIBUTING.md

@@ -45,6 +45,10 @@ sure to include:
     * Any error messages generated
     * Screenshots (if applicable)
 
+* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
+The issue will be reviewed by a moderator after submission and the appropriate
+labels will be applied.
+
 * Keep in mind that we prioritize bugs based on their severity and how
 much work is required to resolve them. It may take some time for someone
 to address your issue.
@@ -91,6 +95,10 @@ following:
     * Any third-party libraries or other resources which would be
       involved
 
+* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue title.
+The issue will be reviewed by a moderator after submission and the appropriate
+labels will be applied.
+
 ## Submitting Pull Requests
 
 * Be sure to open an issue before starting work on a pull request, and

+ 8 - 0
docs/installation/upgrading.md

@@ -58,6 +58,14 @@ This script:
 * Applies any database migrations that were included in the release
 * Collects all static files to be served by the HTTP service
 
+!!! note
+    It's possible that the upgrade script will display a notice warning of unreflected database migrations:
+
+        Your models have changes that are not yet reflected in a migration, and so won't be applied.
+        Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
+
+    This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are inentionally modifying the database schema.
+
 # Restart the WSGI Service
 
 Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:

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

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import serializers
 
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType

+ 2 - 0
netbox/circuits/api/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import routers
 
 from . import views

+ 3 - 1
netbox/circuits/api/views.py

@@ -1,9 +1,11 @@
-from django.shortcuts import get_object_or_404
+from __future__ import unicode_literals
 
 from rest_framework.decorators import detail_route
 from rest_framework.response import Response
 from rest_framework.viewsets import ModelViewSet
 
+from django.shortcuts import get_object_or_404
+
 from circuits import filters
 from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
 from extras.models import Graph, GRAPH_TYPE_PROVIDER

+ 2 - 0
netbox/circuits/apps.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.apps import AppConfig
 
 

+ 2 - 0
netbox/circuits/filters.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 import django_filters
 
 from django.db.models import Q

+ 16 - 14
netbox/circuits/forms.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django import forms
 from django.db.models import Count
 
@@ -165,7 +167,9 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
     )
     rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         required=False,
         label='Rack',
         widget=APISelect(
@@ -175,7 +179,10 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
     )
     device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
-        chains={'site': 'site', 'rack': 'rack'},
+        chains=(
+            ('site', 'site'),
+            ('rack', 'rack'),
+        ),
         required=False,
         label='Device',
         widget=APISelect(
@@ -184,20 +191,13 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
             attrs={'filter-for': 'interface'}
         )
     )
-    livesearch = forms.CharField(
-        required=False,
-        label='Device',
-        widget=Livesearch(
-            query_key='q',
-            query_url='dcim-api:device-list',
-            field_to_update='device'
-        )
-    )
     interface = ChainedModelChoiceField(
         queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
             'circuit_termination', 'connected_as_a', 'connected_as_b'
         ),
-        chains={'device': 'device'},
+        chains=(
+            ('device', 'device'),
+        ),
         required=False,
         label='Interface',
         widget=APISelect(
@@ -208,8 +208,10 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
 
     class Meta:
         model = CircuitTermination
-        fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
-                  'xconnect_id', 'pp_info']
+        fields = [
+            'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id',
+            'pp_info',
+        ]
         help_texts = {
             'port_speed': "Physical circuit speed",
             'xconnect_id': "ID of the local cross-connect",

+ 81 - 0
netbox/circuits/migrations/0009_unicode_literals.py

@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-05-24 15:34
+from __future__ import unicode_literals
+
+import dcim.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0008_circuittermination_interface_protect_on_delete'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='circuit',
+            name='cid',
+            field=models.CharField(max_length=50, verbose_name='Circuit ID'),
+        ),
+        migrations.AlterField(
+            model_name='circuit',
+            name='commit_rate',
+            field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)'),
+        ),
+        migrations.AlterField(
+            model_name='circuit',
+            name='install_date',
+            field=models.DateField(blank=True, null=True, verbose_name='Date installed'),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='port_speed',
+            field=models.PositiveIntegerField(verbose_name='Port speed (Kbps)'),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='pp_info',
+            field=models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)'),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='term_side',
+            field=models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination'),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='upstream_speed',
+            field=models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)'),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='xconnect_id',
+            field=models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID'),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='account',
+            field=models.CharField(blank=True, max_length=30, verbose_name='Account number'),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='admin_contact',
+            field=models.TextField(blank=True, verbose_name='Admin contact'),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='asn',
+            field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='noc_contact',
+            field=models.TextField(blank=True, verbose_name='NOC contact'),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='portal_url',
+            field=models.URLField(blank=True, verbose_name='Portal'),
+        ),
+    ]

+ 4 - 2
netbox/circuits/models.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.urls import reverse
@@ -110,7 +112,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
         unique_together = ['provider', 'cid']
 
     def __str__(self):
-        return u'{} {}'.format(self.provider, self.cid)
+        return '{} {}'.format(self.provider, self.cid)
 
     def get_absolute_url(self):
         return reverse('circuits:circuit', args=[self.pk])
@@ -166,7 +168,7 @@ class CircuitTermination(models.Model):
         unique_together = ['circuit', 'term_side']
 
     def __str__(self):
-        return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
+        return '{} (Side {})'.format(self.circuit, self.get_term_side_display())
 
     def get_peer_termination(self):
         peer_side = 'Z' if self.term_side == 'A' else 'A'

+ 2 - 0
netbox/circuits/signals.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.db.models.signals import post_delete, post_save
 from django.dispatch import receiver
 from django.utils import timezone

+ 2 - 1
netbox/circuits/tables.py

@@ -1,8 +1,9 @@
+from __future__ import unicode_literals
+
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
 from utilities.tables import BaseTable, SearchTable, ToggleColumn
-
 from .models import Circuit, CircuitType, Provider
 
 

+ 2 - 0
netbox/circuits/tests/test_api.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import status
 from rest_framework.test import APITestCase
 

+ 4 - 2
netbox/circuits/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.conf.urls import url
 
 from . import views
@@ -12,7 +14,7 @@ urlpatterns = [
     url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
     url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
     url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
-    url(r'^providers/(?P<slug>[\w-]+)/$', views.provider, name='provider'),
+    url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
     url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
     url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
 
@@ -28,7 +30,7 @@ urlpatterns = [
     url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
     url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
     url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
-    url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
+    url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
     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+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),

+ 37 - 28
netbox/circuits/views.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.contrib import messages
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -5,13 +7,13 @@ from django.db import transaction
 from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
+from django.views.generic import View
 
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
 from utilities.forms import ConfirmationForm
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
-
 from . import filters, forms, tables
 from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
 
@@ -28,18 +30,23 @@ class ProviderListView(ObjectListView):
     template_name = 'circuits/provider_list.html'
 
 
-def provider(request, slug):
+class ProviderView(View):
 
-    provider = get_object_or_404(Provider, slug=slug)
-    circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\
-        .prefetch_related('terminations__site')
-    show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
+    def get(self, request, slug):
 
-    return render(request, 'circuits/provider.html', {
-        'provider': provider,
-        'circuits': circuits,
-        'show_graphs': show_graphs,
-    })
+        provider = get_object_or_404(Provider, slug=slug)
+        circuits = Circuit.objects.filter(provider=provider).select_related(
+            'type', 'tenant'
+        ).prefetch_related(
+            'terminations__site'
+        )
+        show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
+
+        return render(request, 'circuits/provider.html', {
+            'provider': provider,
+            'circuits': circuits,
+            'show_graphs': show_graphs,
+        })
 
 
 class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
@@ -117,25 +124,27 @@ class CircuitListView(ObjectListView):
     template_name = 'circuits/circuit_list.html'
 
 
-def circuit(request, pk):
+class CircuitView(View):
 
-    circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
-    termination_a = CircuitTermination.objects.select_related(
-        'site__region', 'interface__device'
-    ).filter(
-        circuit=circuit, term_side=TERM_SIDE_A
-    ).first()
-    termination_z = CircuitTermination.objects.select_related(
-        'site__region', 'interface__device'
-    ).filter(
-        circuit=circuit, term_side=TERM_SIDE_Z
-    ).first()
+    def get(self, request, pk):
 
-    return render(request, 'circuits/circuit.html', {
-        'circuit': circuit,
-        'termination_a': termination_a,
-        'termination_z': termination_z,
-    })
+        circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
+        termination_a = CircuitTermination.objects.select_related(
+            'site__region', 'interface__device'
+        ).filter(
+            circuit=circuit, term_side=TERM_SIDE_A
+        ).first()
+        termination_z = CircuitTermination.objects.select_related(
+            'site__region', 'interface__device'
+        ).filter(
+            circuit=circuit, term_side=TERM_SIDE_Z
+        ).first()
+
+        return render(request, 'circuits/circuit.html', {
+            'circuit': circuit,
+            'termination_a': termination_a,
+            'termination_z': termination_z,
+        })
 
 
 class CircuitEditView(PermissionRequiredMixin, ObjectEditView):

+ 2 - 0
netbox/dcim/api/exceptions.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework.exceptions import APIException
 
 

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

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 
@@ -618,10 +620,11 @@ class PeerInterfaceSerializer(serializers.ModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
+    lag = NestedInterfaceSerializer()
 
     class Meta:
         model = Interface
-        fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
+        fields = ['id', 'url', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
 
 
 class WritableInterfaceSerializer(serializers.ModelSerializer):

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

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import routers
 
 from . import views

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

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework.decorators import detail_route
 from rest_framework.mixins import ListModelMixin
 from rest_framework.permissions import IsAuthenticated

+ 2 - 0
netbox/dcim/apps.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.apps import AppConfig
 
 

+ 2 - 0
netbox/dcim/fields.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from netaddr import EUI, mac_unix_expanded
 
 from django.core.exceptions import ValidationError

+ 2 - 0
netbox/dcim/filters.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 import django_filters
 from netaddr.core import AddrFormatError
 

+ 2 - 0
netbox/dcim/formfields.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from netaddr import EUI, AddrFormatError
 
 from django import forms

+ 91 - 30
netbox/dcim/forms.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from mptt.forms import TreeNodeChoiceField
 import re
 
@@ -16,7 +18,6 @@ from utilities.forms import (
     FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
     FilterTreeNodeMultipleChoiceField,
 )
-
 from .formfields import MACAddressFormField
 from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
@@ -189,7 +190,9 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
 class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     group = ChainedModelChoiceField(
         queryset=RackGroup.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         required=False,
         widget=APISelect(
             api_url='/api/dcim/rack-groups/?site_id={{site}}',
@@ -544,7 +547,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     )
     rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         required=False,
         widget=APISelect(
             api_url='/api/dcim/racks/?site_id={{site}}',
@@ -569,7 +574,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     )
     device_type = ChainedModelChoiceField(
         queryset=DeviceType.objects.all(),
-        chains={'manufacturer': 'manufacturer'},
+        chains=(
+            ('manufacturer', 'manufacturer'),
+        ),
         label='Device type',
         widget=APISelect(
             api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
@@ -610,10 +617,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             for family in [4, 6]:
                 ip_choices = []
                 interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
-                ip_choices += [(ip.id, u'{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
+                ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
                 nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
                     .select_related('nat_inside__interface')
-                ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
+                ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
                 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
@@ -804,7 +811,7 @@ def device_status_choices():
     status_counts = {}
     for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
 
 
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -956,20 +963,29 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
 class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
-        widget=forms.HiddenInput(),
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
     )
     rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         label='Rack',
         required=False,
-        widget=forms.Select(
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
             attrs={'filter-for': 'console_server', 'nullable': 'true'}
         )
     )
     console_server = ChainedModelChoiceField(
         queryset=Device.objects.filter(device_type__is_console_server=True),
-        chains={'site': 'site', 'rack': 'rack'},
+        chains=(
+            ('site', 'site'),
+            ('rack', 'rack'),
+        ),
         label='Console Server',
         required=False,
         widget=APISelect(
@@ -989,7 +1005,9 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF
     )
     cs_port = ChainedModelChoiceField(
         queryset=ConsoleServerPort.objects.all(),
-        chains={'device': 'console_server'},
+        chains=(
+            ('device', 'console_server'),
+        ),
         label='Port',
         widget=APISelect(
             api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
@@ -1034,20 +1052,29 @@ class ConsoleServerPortCreateForm(DeviceComponentForm):
 class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
-        widget=forms.HiddenInput(),
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
     )
     rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         label='Rack',
         required=False,
-        widget=forms.Select(
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
             attrs={'filter-for': 'device', 'nullable': 'true'}
         )
     )
     device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
-        chains={'site': 'site', 'rack': 'rack'},
+        chains=(
+            ('site', 'site'),
+            ('rack', 'rack'),
+        ),
         label='Device',
         required=False,
         widget=APISelect(
@@ -1067,7 +1094,9 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
     )
     port = ChainedModelChoiceField(
         queryset=ConsolePort.objects.all(),
-        chains={'device': 'device'},
+        chains=(
+            ('device', 'device'),
+        ),
         label='Port',
         widget=APISelect(
             api_url='/api/dcim/console-ports/?device_id={{device}}',
@@ -1181,19 +1210,31 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
 
 
 class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
+    )
     rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         label='Rack',
         required=False,
-        widget=forms.Select(
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
             attrs={'filter-for': 'pdu', 'nullable': 'true'}
         )
     )
     pdu = ChainedModelChoiceField(
         queryset=Device.objects.all(),
-        chains={'site': 'site', 'rack': 'rack'},
+        chains=(
+            ('site', 'site'),
+            ('rack', 'rack'),
+        ),
         label='PDU',
         required=False,
         widget=APISelect(
@@ -1213,7 +1254,9 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
     )
     power_outlet = ChainedModelChoiceField(
         queryset=PowerOutlet.objects.all(),
-        chains={'device': 'pdu'},
+        chains=(
+            ('device', 'pdu'),
+        ),
         label='Outlet',
         widget=APISelect(
             api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
@@ -1258,20 +1301,29 @@ class PowerOutletCreateForm(DeviceComponentForm):
 class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
-        widget=forms.HiddenInput()
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'rack'}
+        )
     )
     rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         label='Rack',
         required=False,
-        widget=forms.Select(
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
             attrs={'filter-for': 'device', 'nullable': 'true'}
         )
     )
     device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
-        chains={'site': 'site', 'rack': 'rack'},
+        chains=(
+            ('site', 'site'),
+            ('rack', 'rack'),
+        ),
         label='Device',
         required=False,
         widget=APISelect(
@@ -1291,7 +1343,9 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
     )
     port = ChainedModelChoiceField(
         queryset=PowerPort.objects.all(),
-        chains={'device': 'device'},
+        chains=(
+            ('device', 'device'),
+        ),
         label='Port',
         widget=APISelect(
             api_url='/api/dcim/power-ports/?device_id={{device}}',
@@ -1411,7 +1465,9 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
     )
     rack_b = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'site_b'},
+        chains=(
+            ('site', 'site_b'),
+        ),
         label='Rack',
         required=False,
         widget=APISelect(
@@ -1421,7 +1477,10 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
     )
     device_b = ChainedModelChoiceField(
         queryset=Device.objects.all(),
-        chains={'site': 'site_b', 'rack': 'rack_b'},
+        chains=(
+            ('site', 'site_b'),
+            ('rack', 'rack_b'),
+        ),
         label='Device',
         required=False,
         widget=APISelect(
@@ -1443,7 +1502,9 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
         queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
             'circuit_termination', 'connected_as_a', 'connected_as_b'
         ),
-        chains={'device': 'device_b'},
+        chains=(
+            ('device', 'device_b'),
+        ),
         label='Interface',
         widget=APISelect(
             api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',

+ 209 - 0
netbox/dcim/migrations/0037_unicode_literals.py

@@ -0,0 +1,209 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-05-24 15:34
+from __future__ import unicode_literals
+
+import dcim.fields
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0036_add_ff_juniper_vcp'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='consoleport',
+            name='connection_status',
+            field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleport',
+            name='cs_port',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name='Console server port'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='asset_tag',
+            field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name='Asset tag'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='face',
+            field=models.PositiveSmallIntegerField(blank=True, choices=[[0, 'Front'], [1, 'Rear']], null=True, verbose_name='Rack face'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='position',
+            field=models.PositiveSmallIntegerField(blank=True, help_text='The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Position (U)'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='primary_ip4',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name='Primary IPv4'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='primary_ip6',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name='Primary IPv6'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='serial',
+            field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [2, 'Planned'], [3, 'Staged'], [4, 'Failed'], [5, 'Inventory']], default=1, verbose_name='Status'),
+        ),
+        migrations.AlterField(
+            model_name='devicebay',
+            name='name',
+            field=models.CharField(max_length=50, verbose_name='Name'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='interface_ordering',
+            field=models.PositiveSmallIntegerField(choices=[[1, 'Slot/position'], [2, 'Name (alphabetically)']], default=1),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='is_console_server',
+            field=models.BooleanField(default=False, help_text='This type of device has console server ports', verbose_name='Is a console server'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='is_full_depth',
+            field=models.BooleanField(default=True, help_text='Device consumes both front and rear rack faces', verbose_name='Is full depth'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='is_network_device',
+            field=models.BooleanField(default=True, help_text='This type of device has network interfaces', verbose_name='Is a network device'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='is_pdu',
+            field=models.BooleanField(default=False, help_text='This type of device has power outlets', verbose_name='Is a PDU'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='part_number',
+            field=models.CharField(blank=True, help_text='Discrete part number (optional)', max_length=50),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='subdevice_role',
+            field=models.NullBooleanField(choices=[(None, 'None'), (True, 'Parent'), (False, 'Child')], default=None, help_text='Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name='Parent/child status'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='u_height',
+            field=models.PositiveSmallIntegerField(default=1, verbose_name='Height (U)'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='lag',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name='Parent LAG'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='mac_address',
+            field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='mgmt_only',
+            field=models.BooleanField(default=False, help_text='This interface is used only for out-of-band management', verbose_name='OOB Management'),
+        ),
+        migrations.AlterField(
+            model_name='interfaceconnection',
+            name='connection_status',
+            field=models.BooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True, verbose_name='Status'),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='mgmt_only',
+            field=models.BooleanField(default=False, verbose_name='Management only'),
+        ),
+        migrations.AlterField(
+            model_name='inventoryitem',
+            name='discovered',
+            field=models.BooleanField(default=False, verbose_name='Discovered'),
+        ),
+        migrations.AlterField(
+            model_name='inventoryitem',
+            name='name',
+            field=models.CharField(max_length=50, verbose_name='Name'),
+        ),
+        migrations.AlterField(
+            model_name='inventoryitem',
+            name='part_id',
+            field=models.CharField(blank=True, max_length=50, verbose_name='Part ID'),
+        ),
+        migrations.AlterField(
+            model_name='inventoryitem',
+            name='serial',
+            field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
+        ),
+        migrations.AlterField(
+            model_name='platform',
+            name='rpc_client',
+            field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='RPC client'),
+        ),
+        migrations.AlterField(
+            model_name='powerport',
+            name='connection_status',
+            field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='desc_units',
+            field=models.BooleanField(default=False, help_text='Units are numbered top-to-bottom', verbose_name='Descending units'),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='facility_id',
+            field=utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name='Facility ID'),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='type',
+            field=models.PositiveSmallIntegerField(blank=True, choices=[(100, '2-post frame'), (200, '4-post frame'), (300, '4-post cabinet'), (1000, 'Wall-mounted frame'), (1100, 'Wall-mounted cabinet')], null=True, verbose_name='Type'),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='u_height',
+            field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Height (U)'),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='width',
+            field=models.PositiveSmallIntegerField(choices=[(19, '19 inches'), (23, '23 inches')], default=19, help_text='Rail-to-rail width', verbose_name='Width'),
+        ),
+        migrations.AlterField(
+            model_name='site',
+            name='asn',
+            field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'),
+        ),
+        migrations.AlterField(
+            model_name='site',
+            name='contact_email',
+            field=models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail'),
+        ),
+    ]

+ 13 - 13
netbox/dcim/models.py

@@ -1,3 +1,4 @@
+from __future__ import unicode_literals
 from collections import OrderedDict
 from itertools import count, groupby
 
@@ -23,7 +24,6 @@ from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
 from utilities.utils import csv_format
-
 from .fields import ASNField, MACAddressField
 
 
@@ -346,7 +346,7 @@ class RackGroup(models.Model):
         ]
 
     def __str__(self):
-        return u'{} - {}'.format(self.site.name, self.name)
+        return '{} - {}'.format(self.site.name, self.name)
 
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@@ -466,10 +466,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
     @property
     def display_name(self):
         if self.facility_id:
-            return u"{} ({})".format(self.name, self.facility_id)
+            return "{} ({})".format(self.name, self.facility_id)
         elif self.name:
             return self.name
-        return u""
+        return ""
 
     def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
         """
@@ -569,7 +569,7 @@ class RackReservation(models.Model):
         ordering = ['created']
 
     def __str__(self):
-        return u"Reservation for rack {}".format(self.rack)
+        return "Reservation for rack {}".format(self.rack)
 
     def clean(self):
 
@@ -579,7 +579,7 @@ class RackReservation(models.Model):
             invalid_units = [u for u in self.units if u not in self.rack.units]
             if invalid_units:
                 raise ValidationError({
-                    'units': u"Invalid unit(s) for {}U rack: {}".format(
+                    'units': "Invalid unit(s) for {}U rack: {}".format(
                         self.rack.u_height,
                         ', '.join([str(u) for u in invalid_units]),
                     ),
@@ -733,7 +733,7 @@ class DeviceType(models.Model, CustomFieldModel):
 
     @property
     def full_name(self):
-        return u'{} {}'.format(self.manufacturer.name, self.model)
+        return '{} {}'.format(self.manufacturer.name, self.model)
 
     @property
     def is_parent_device(self):
@@ -1106,8 +1106,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         if self.name:
             return self.name
         elif hasattr(self, 'device_type'):
-            return u"{}".format(self.device_type)
-        return u""
+            return "{}".format(self.device_type)
+        return ""
 
     @property
     def identifier(self):
@@ -1320,7 +1320,7 @@ class Interface(models.Model):
         # An interface's LAG must belong to the same device
         if self.lag and self.lag.device != self.device:
             raise ValidationError({
-                'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format(
+                'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
                     self.lag.name, self.lag.device.name
                 )
             })
@@ -1328,14 +1328,14 @@ class Interface(models.Model):
         # A virtual interface cannot have a parent LAG
         if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None:
             raise ValidationError({
-                'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
+                'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
             })
 
         # Only a LAG can have LAG members
         if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
             raise ValidationError({
                 'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
-                    u", ".join([iface.name for iface in self.member_interfaces.all()])
+                    ", ".join([iface.name for iface in self.member_interfaces.all()])
                 )
             })
 
@@ -1428,7 +1428,7 @@ class DeviceBay(models.Model):
         unique_together = ['device', 'name']
 
     def __str__(self):
-        return u'{} - {}'.format(self.device.name, self.name)
+        return '{} - {}'.format(self.device.name, self.name)
 
     def clean(self):
 

+ 2 - 1
netbox/dcim/tables.py

@@ -1,8 +1,9 @@
+from __future__ import unicode_literals
+
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
 from utilities.tables import BaseTable, SearchTable, ToggleColumn
-
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
     Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,

+ 2 - 0
netbox/dcim/tests/test_api.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import status
 from rest_framework.test import APITestCase
 

+ 3 - 0
netbox/dcim/tests/test_forms.py

@@ -1,4 +1,7 @@
+from __future__ import unicode_literals
+
 from django.test import TestCase
+
 from dcim.forms import *
 from dcim.models import *
 

+ 3 - 0
netbox/dcim/tests/test_models.py

@@ -1,4 +1,7 @@
+from __future__ import unicode_literals
+
 from django.test import TestCase
+
 from dcim.models import *
 
 

+ 9 - 8
netbox/dcim/urls.py

@@ -1,9 +1,10 @@
+from __future__ import unicode_literals
+
 from django.conf.urls import url
 
+from extras.views import ImageAttachmentEditView
 from ipam.views import ServiceEditView
 from secrets.views import secret_add
-
-from extras.views import ImageAttachmentEditView
 from .models import Device, Rack, Site
 from . import views
 
@@ -22,7 +23,7 @@ urlpatterns = [
     url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
     url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
     url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
-    url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
+    url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
     url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
@@ -52,7 +53,7 @@ urlpatterns = [
     url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
     url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
     url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
-    url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'),
+    url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
     url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
@@ -69,7 +70,7 @@ urlpatterns = [
     url(r'^device-types/add/$', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
     url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
     url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
-    url(r'^device-types/(?P<pk>\d+)/$', views.devicetype, name='devicetype'),
+    url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
     url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
 
@@ -117,11 +118,11 @@ urlpatterns = [
     url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
     url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
     url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
-    url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),
+    url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
     url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
     url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
-    url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
-    url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
+    url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
+    url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     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'),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),

+ 248 - 215
netbox/dcim/views.py

@@ -1,3 +1,4 @@
+from __future__ import unicode_literals
 from copy import deepcopy
 import re
 from natsort import natsorted
@@ -24,7 +25,6 @@ from utilities.paginator import EnhancedPaginator
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
-
 from . import filters, forms, tables
 from .models import (
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
@@ -109,11 +109,11 @@ class ComponentCreateView(View):
                         if field == 'name':
                             field = 'name_pattern'
                         for e in errors:
-                            form.add_error(field, u'{}: {}'.format(name, ', '.join(e)))
+                            form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
 
             if not form.errors:
                 self.model.objects.bulk_create(new_components)
-                messages.success(request, u"Added {} {} to {}.".format(
+                messages.success(request, "Added {} {} to {}.".format(
                     len(new_components), self.model._meta.verbose_name_plural, parent
                 ))
                 if '_addanother' in request.POST:
@@ -178,27 +178,29 @@ class SiteListView(ObjectListView):
     template_name = 'dcim/site_list.html'
 
 
-def site(request, slug):
-
-    site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug)
-    stats = {
-        'rack_count': Rack.objects.filter(site=site).count(),
-        'device_count': Device.objects.filter(site=site).count(),
-        'prefix_count': Prefix.objects.filter(site=site).count(),
-        'vlan_count': VLAN.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'))
-    topology_maps = TopologyMap.objects.filter(site=site)
-    show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists()
-
-    return render(request, 'dcim/site.html', {
-        'site': site,
-        'stats': stats,
-        'rack_groups': rack_groups,
-        'topology_maps': topology_maps,
-        'show_graphs': show_graphs,
-    })
+class SiteView(View):
+
+    def get(self, request, slug):
+
+        site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug)
+        stats = {
+            'rack_count': Rack.objects.filter(site=site).count(),
+            'device_count': Device.objects.filter(site=site).count(),
+            'prefix_count': Prefix.objects.filter(site=site).count(),
+            'vlan_count': VLAN.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'))
+        topology_maps = TopologyMap.objects.filter(site=site)
+        show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists()
+
+        return render(request, 'dcim/site.html', {
+            'site': site,
+            'stats': stats,
+            'rack_groups': rack_groups,
+            'topology_maps': topology_maps,
+            'show_graphs': show_graphs,
+        })
 
 
 class SiteEditView(PermissionRequiredMixin, ObjectEditView):
@@ -290,8 +292,13 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class RackListView(ObjectListView):
-    queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\
-        .annotate(device_count=Count('devices', distinct=True))
+    queryset = Rack.objects.select_related(
+        'site', 'group', 'tenant', 'role'
+    ).prefetch_related(
+        'devices__device_type'
+    ).annotate(
+        device_count=Count('devices', distinct=True)
+    )
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     table = tables.RackTable
@@ -338,31 +345,33 @@ class RackElevationListView(View):
         })
 
 
-def rack(request, pk):
-
-    rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
+class RackView(View):
 
-    nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\
-        .select_related('device_type__manufacturer')
-    next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
-    prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
-
-    reservations = RackReservation.objects.filter(rack=rack)
-    reserved_units = {}
-    for r in reservations:
-        for u in r.units:
-            reserved_units[u] = r
+    def get(self, request, pk):
 
-    return render(request, 'dcim/rack.html', {
-        'rack': rack,
-        'reservations': reservations,
-        'reserved_units': reserved_units,
-        'nonracked_devices': nonracked_devices,
-        'next_rack': next_rack,
-        'prev_rack': prev_rack,
-        'front_elevation': rack.get_front_elevation(),
-        'rear_elevation': rack.get_rear_elevation(),
-    })
+        rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
+
+        nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\
+            .select_related('device_type__manufacturer')
+        next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
+        prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
+
+        reservations = RackReservation.objects.filter(rack=rack)
+        reserved_units = {}
+        for r in reservations:
+            for u in r.units:
+                reserved_units[u] = r
+
+        return render(request, 'dcim/rack.html', {
+            'rack': rack,
+            'reservations': reservations,
+            'reserved_units': reserved_units,
+            'nonracked_devices': nonracked_devices,
+            'next_rack': next_rack,
+            'prev_rack': prev_rack,
+            'front_elevation': rack.get_front_elevation(),
+            'rear_elevation': rack.get_rear_elevation(),
+        })
 
 
 class RackEditView(PermissionRequiredMixin, ObjectEditView):
@@ -481,53 +490,57 @@ class DeviceTypeListView(ObjectListView):
     template_name = 'dcim/devicetype_list.html'
 
 
-def devicetype(request, pk):
+class DeviceTypeView(View):
 
-    devicetype = get_object_or_404(DeviceType, pk=pk)
+    def get(self, request, pk):
 
-    # Component tables
-    consoleport_table = tables.ConsolePortTemplateTable(
-        natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
-    )
-    consoleserverport_table = tables.ConsoleServerPortTemplateTable(
-        natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
-    )
-    powerport_table = tables.PowerPortTemplateTable(
-        natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
-    )
-    poweroutlet_table = tables.PowerOutletTemplateTable(
-        natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
-    )
-    mgmt_interface_table = tables.InterfaceTemplateTable(
-        list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
-                                                                                             mgmt_only=True))
-    )
-    interface_table = tables.InterfaceTemplateTable(
-        list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
-                                                                                             mgmt_only=False))
-    )
-    devicebay_table = tables.DeviceBayTemplateTable(
-        natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
-    )
-    if request.user.has_perm('dcim.change_devicetype'):
-        consoleport_table.base_columns['pk'].visible = True
-        consoleserverport_table.base_columns['pk'].visible = True
-        powerport_table.base_columns['pk'].visible = True
-        poweroutlet_table.base_columns['pk'].visible = True
-        mgmt_interface_table.base_columns['pk'].visible = True
-        interface_table.base_columns['pk'].visible = True
-        devicebay_table.base_columns['pk'].visible = True
-
-    return render(request, 'dcim/devicetype.html', {
-        'devicetype': devicetype,
-        'consoleport_table': consoleport_table,
-        'consoleserverport_table': consoleserverport_table,
-        'powerport_table': powerport_table,
-        'poweroutlet_table': poweroutlet_table,
-        'mgmt_interface_table': mgmt_interface_table,
-        'interface_table': interface_table,
-        'devicebay_table': devicebay_table,
-    })
+        devicetype = get_object_or_404(DeviceType, pk=pk)
+
+        # Component tables
+        consoleport_table = tables.ConsolePortTemplateTable(
+            natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+        )
+        consoleserverport_table = tables.ConsoleServerPortTemplateTable(
+            natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+        )
+        powerport_table = tables.PowerPortTemplateTable(
+            natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+        )
+        poweroutlet_table = tables.PowerOutletTemplateTable(
+            natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+        )
+        mgmt_interface_table = tables.InterfaceTemplateTable(
+            list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(
+                device_type=devicetype, mgmt_only=True
+            ))
+        )
+        interface_table = tables.InterfaceTemplateTable(
+            list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(
+                device_type=devicetype, mgmt_only=False
+            ))
+        )
+        devicebay_table = tables.DeviceBayTemplateTable(
+            natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+        )
+        if request.user.has_perm('dcim.change_devicetype'):
+            consoleport_table.base_columns['pk'].visible = True
+            consoleserverport_table.base_columns['pk'].visible = True
+            powerport_table.base_columns['pk'].visible = True
+            poweroutlet_table.base_columns['pk'].visible = True
+            mgmt_interface_table.base_columns['pk'].visible = True
+            interface_table.base_columns['pk'].visible = True
+            devicebay_table.base_columns['pk'].visible = True
+
+        return render(request, 'dcim/devicetype.html', {
+            'devicetype': devicetype,
+            'consoleport_table': consoleport_table,
+            'consoleserverport_table': consoleserverport_table,
+            'powerport_table': powerport_table,
+            'poweroutlet_table': poweroutlet_table,
+            'mgmt_interface_table': mgmt_interface_table,
+            'interface_table': interface_table,
+            'devicebay_table': devicebay_table,
+        })
 
 
 class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
@@ -727,70 +740,114 @@ class DeviceListView(ObjectListView):
     template_name = 'dcim/device_list.html'
 
 
-def device(request, pk):
+class DeviceView(View):
 
-    device = get_object_or_404(Device.objects.select_related(
-        'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
-    ), pk=pk)
-    console_ports = natsorted(
-        ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
-    )
-    cs_ports = natsorted(
-        ConsoleServerPort.objects.filter(device=device).select_related('connected_console'), key=attrgetter('name')
-    )
-    power_ports = natsorted(
-        PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name')
-    )
-    power_outlets = natsorted(
-        PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
-    )
-    interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
-        .filter(device=device, mgmt_only=False)\
-        .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
-                        'circuit_termination__circuit').prefetch_related('ip_addresses')
-    mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
-        .filter(device=device, mgmt_only=True)\
-        .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
-                        'circuit_termination__circuit').prefetch_related('ip_addresses')
-    device_bays = natsorted(
-        DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
-        key=attrgetter('name')
-    )
-    services = Service.objects.filter(device=device)
-    secrets = device.secrets.all()
-
-    # Find any related devices for convenient linking in the UI
-    related_devices = []
-    if device.name:
-        if re.match('.+[0-9]+$', device.name):
-            # Strip 1 or more trailing digits (e.g. core-switch1)
-            base_name = re.match('(.*?)[0-9]+$', device.name).group(1)
-        elif re.match('.+\d[a-z]$', device.name.lower()):
-            # Strip a trailing letter if preceded by a digit (e.g. dist-switch3a -> dist-switch3)
-            base_name = re.match('(.*\d+)[a-z]$', device.name.lower()).group(1)
-        else:
-            base_name = None
-        if base_name:
-            related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk)\
-                .select_related('rack', 'device_type__manufacturer')[:10]
+    def get(self, request, pk):
 
-    # Show graph button on interfaces only if at least one graph has been created.
-    show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
+        device = get_object_or_404(Device.objects.select_related(
+            'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
+        ), pk=pk)
+        console_ports = natsorted(
+            ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
+        )
+        cs_ports = natsorted(
+            ConsoleServerPort.objects.filter(device=device).select_related('connected_console'), key=attrgetter('name')
+        )
+        power_ports = natsorted(
+            PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name')
+        )
+        power_outlets = natsorted(
+            PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
+        )
+        interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(
+            device=device, mgmt_only=False
+        ).select_related(
+            'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
+            'circuit_termination__circuit'
+        ).prefetch_related('ip_addresses')
+        mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(
+            device=device, mgmt_only=True
+        ).select_related(
+            'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
+            'circuit_termination__circuit'
+        ).prefetch_related('ip_addresses')
+        device_bays = natsorted(
+            DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
+            key=attrgetter('name')
+        )
+        services = Service.objects.filter(device=device)
+        secrets = device.secrets.all()
+
+        # Find any related devices for convenient linking in the UI
+        related_devices = []
+        if device.name:
+            if re.match('.+[0-9]+$', device.name):
+                # Strip 1 or more trailing digits (e.g. core-switch1)
+                base_name = re.match('(.*?)[0-9]+$', device.name).group(1)
+            elif re.match('.+\d[a-z]$', device.name.lower()):
+                # Strip a trailing letter if preceded by a digit (e.g. dist-switch3a -> dist-switch3)
+                base_name = re.match('(.*\d+)[a-z]$', device.name.lower()).group(1)
+            else:
+                base_name = None
+            if base_name:
+                related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk)\
+                    .select_related('rack', 'device_type__manufacturer')[:10]
+
+        # Show graph button on interfaces only if at least one graph has been created.
+        show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
+
+        return render(request, 'dcim/device.html', {
+            'device': device,
+            'console_ports': console_ports,
+            'cs_ports': cs_ports,
+            'power_ports': power_ports,
+            'power_outlets': power_outlets,
+            'interfaces': interfaces,
+            'mgmt_interfaces': mgmt_interfaces,
+            'device_bays': device_bays,
+            'services': services,
+            'secrets': secrets,
+            'related_devices': related_devices,
+            'show_graphs': show_graphs,
+        })
 
-    return render(request, 'dcim/device.html', {
-        'device': device,
-        'console_ports': console_ports,
-        'cs_ports': cs_ports,
-        'power_ports': power_ports,
-        'power_outlets': power_outlets,
-        'interfaces': interfaces,
-        'mgmt_interfaces': mgmt_interfaces,
-        'device_bays': device_bays,
-        'services': services,
-        'secrets': secrets,
-        'related_devices': related_devices,
-        'show_graphs': show_graphs,
-    })
+
+class DeviceInventoryView(View):
+
+    def get(self, request, pk):
+
+        device = get_object_or_404(Device, pk=pk)
+        inventory_items = InventoryItem.objects.filter(
+            device=device, parent=None
+        ).select_related(
+            'manufacturer'
+        ).prefetch_related(
+            'child_items'
+        )
+
+        return render(request, 'dcim/device_inventory.html', {
+            'device': device,
+            'inventory_items': inventory_items,
+        })
+
+
+class DeviceLLDPNeighborsView(View):
+
+    def get(self, request, pk):
+
+        device = get_object_or_404(Device, pk=pk)
+        interfaces = Interface.objects.order_naturally(
+            device.device_type.interface_ordering
+        ).filter(
+            device=device
+        ).select_related(
+            'connected_as_a', 'connected_as_b'
+        )
+
+        return render(request, 'dcim/device_lldp_neighbors.html', {
+            'device': device,
+            'interfaces': interfaces,
+        })
 
 
 class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
@@ -851,30 +908,6 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'dcim:device_list'
 
 
-def device_inventory(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-    inventory_items = InventoryItem.objects.filter(device=device, parent=None).select_related('manufacturer')\
-        .prefetch_related('child_items')
-
-    return render(request, 'dcim/device_inventory.html', {
-        'device': device,
-        'inventory_items': inventory_items,
-    })
-
-
-def device_lldp_neighbors(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-    interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
-        .select_related('connected_as_a', 'connected_as_b')
-
-    return render(request, 'dcim/device_lldp_neighbors.html', {
-        'device': device,
-        'interfaces': interfaces,
-    })
-
-
 #
 # Console ports
 #
@@ -897,7 +930,7 @@ def consoleport_connect(request, pk):
         form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport)
         if form.is_valid():
             consoleport = form.save()
-            msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
+            msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
                 consoleport.device.get_absolute_url(),
                 escape(consoleport.device),
                 escape(consoleport.name),
@@ -911,9 +944,9 @@ def consoleport_connect(request, pk):
 
     else:
         form = forms.ConsolePortConnectionForm(instance=consoleport, initial={
-            'site': request.GET.get('site', consoleport.device.site),
-            'rack': request.GET.get('rack', None),
-            'console_server': request.GET.get('console_server', None),
+            'site': request.GET.get('site'),
+            'rack': request.GET.get('rack'),
+            'console_server': request.GET.get('console_server'),
             'connection_status': CONNECTION_STATUS_CONNECTED,
         })
 
@@ -931,7 +964,7 @@ def consoleport_disconnect(request, pk):
 
     if not consoleport.cs_port:
         messages.warning(
-            request, u"Cannot disconnect console port {}: It is not connected to anything.".format(consoleport)
+            request, "Cannot disconnect console port {}: It is not connected to anything.".format(consoleport)
         )
         return redirect('dcim:device', pk=consoleport.device.pk)
 
@@ -942,7 +975,7 @@ def consoleport_disconnect(request, pk):
             consoleport.cs_port = None
             consoleport.connection_status = None
             consoleport.save()
-            msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
+            msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
                 consoleport.device.get_absolute_url(),
                 escape(consoleport.device),
                 escape(consoleport.name),
@@ -1014,7 +1047,7 @@ def consoleserverport_connect(request, pk):
             consoleport.cs_port = consoleserverport
             consoleport.connection_status = form.cleaned_data['connection_status']
             consoleport.save()
-            msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
+            msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
                 consoleport.device.get_absolute_url(),
                 escape(consoleport.device),
                 escape(consoleport.name),
@@ -1028,9 +1061,9 @@ def consoleserverport_connect(request, pk):
 
     else:
         form = forms.ConsoleServerPortConnectionForm(initial={
-            'site': request.GET.get('site', consoleserverport.device.site),
-            'rack': request.GET.get('rack', None),
-            'device': request.GET.get('device', None),
+            'site': request.GET.get('site'),
+            'rack': request.GET.get('rack'),
+            'device': request.GET.get('device'),
             'connection_status': CONNECTION_STATUS_CONNECTED,
         })
 
@@ -1048,7 +1081,7 @@ def consoleserverport_disconnect(request, pk):
 
     if not hasattr(consoleserverport, 'connected_console'):
         messages.warning(
-            request, u"Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport)
+            request, "Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport)
         )
         return redirect('dcim:device', pk=consoleserverport.device.pk)
 
@@ -1059,7 +1092,7 @@ def consoleserverport_disconnect(request, pk):
             consoleport.cs_port = None
             consoleport.connection_status = None
             consoleport.save()
-            msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
+            msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
                 consoleport.device.get_absolute_url(),
                 escape(consoleport.device),
                 escape(consoleport.name),
@@ -1120,7 +1153,7 @@ def powerport_connect(request, pk):
         form = forms.PowerPortConnectionForm(request.POST, instance=powerport)
         if form.is_valid():
             powerport = form.save()
-            msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
+            msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
                 powerport.device.get_absolute_url(),
                 escape(powerport.device),
                 escape(powerport.name),
@@ -1134,9 +1167,9 @@ def powerport_connect(request, pk):
 
     else:
         form = forms.PowerPortConnectionForm(instance=powerport, initial={
-            'site': request.GET.get('site', powerport.device.site),
-            'rack': request.GET.get('rack', None),
-            'pdu': request.GET.get('pdu', None),
+            'site': request.GET.get('site'),
+            'rack': request.GET.get('rack'),
+            'pdu': request.GET.get('pdu'),
             'connection_status': CONNECTION_STATUS_CONNECTED,
         })
 
@@ -1154,7 +1187,7 @@ def powerport_disconnect(request, pk):
 
     if not powerport.power_outlet:
         messages.warning(
-            request, u"Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport)
+            request, "Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport)
         )
         return redirect('dcim:device', pk=powerport.device.pk)
 
@@ -1165,7 +1198,7 @@ def powerport_disconnect(request, pk):
             powerport.power_outlet = None
             powerport.connection_status = None
             powerport.save()
-            msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
+            msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
                 powerport.device.get_absolute_url(),
                 escape(powerport.device),
                 escape(powerport.name),
@@ -1237,7 +1270,7 @@ def poweroutlet_connect(request, pk):
             powerport.power_outlet = poweroutlet
             powerport.connection_status = form.cleaned_data['connection_status']
             powerport.save()
-            msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
+            msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
                 powerport.device.get_absolute_url(),
                 escape(powerport.device),
                 escape(powerport.name),
@@ -1251,9 +1284,9 @@ def poweroutlet_connect(request, pk):
 
     else:
         form = forms.PowerOutletConnectionForm(initial={
-            'site': request.GET.get('site', poweroutlet.device.site),
-            'rack': request.GET.get('rack', None),
-            'device': request.GET.get('device', None),
+            'site': request.GET.get('site'),
+            'rack': request.GET.get('rack'),
+            'device': request.GET.get('device'),
             'connection_status': CONNECTION_STATUS_CONNECTED,
         })
 
@@ -1271,7 +1304,7 @@ def poweroutlet_disconnect(request, pk):
 
     if not hasattr(poweroutlet, 'connected_port'):
         messages.warning(
-            request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet)
+            request, "Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet)
         )
         return redirect('dcim:device', pk=poweroutlet.device.pk)
 
@@ -1282,7 +1315,7 @@ def poweroutlet_disconnect(request, pk):
             powerport.power_outlet = None
             powerport.connection_status = None
             powerport.save()
-            msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
+            msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
                 powerport.device.get_absolute_url(),
                 escape(powerport.device),
                 escape(powerport.name),
@@ -1396,7 +1429,7 @@ def devicebay_populate(request, pk):
             device_bay.save()
 
             if not form.errors:
-                messages.success(request, u"Added {} to {}.".format(device_bay.installed_device, device_bay))
+                messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
                 return redirect('dcim:device', pk=device_bay.device.pk)
 
     else:
@@ -1420,7 +1453,7 @@ def devicebay_depopulate(request, pk):
             removed_device = device_bay.installed_device
             device_bay.installed_device = None
             device_bay.save()
-            messages.success(request, u"{} has been removed from {}.".format(removed_device, device_bay))
+            messages.success(request, "{} has been removed from {}.".format(removed_device, device_bay))
             return redirect('dcim:device', pk=device_bay.device.pk)
 
     else:
@@ -1483,11 +1516,11 @@ class DeviceBulkAddComponentView(View):
                         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)))
+                                    form.add_error(field, '{} {}: {}'.format(device, name, ', '.join(e)))
 
                 if not form.errors:
                     self.model.objects.bulk_create(new_components)
-                    messages.success(request, u"Added {} {} to {} devices.".format(
+                    messages.success(request, "Added {} {} to {} devices.".format(
                         len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk'])
                     ))
                     return redirect('dcim:device_list')
@@ -1497,7 +1530,7 @@ class DeviceBulkAddComponentView(View):
 
         selected_devices = Device.objects.filter(pk__in=pk_list)
         if not selected_devices:
-            messages.warning(request, u"No devices were selected.")
+            messages.warning(request, "No devices were selected.")
             return redirect('dcim:device_list')
 
         return render(request, 'dcim/device_bulk_add_component.html', {
@@ -1559,7 +1592,7 @@ def interfaceconnection_add(request, pk):
         if form.is_valid():
 
             interfaceconnection = form.save()
-            msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
+            msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
                 interfaceconnection.interface_a.device.get_absolute_url(),
                 escape(interfaceconnection.interface_a.device),
                 escape(interfaceconnection.interface_a.name),
@@ -1583,11 +1616,11 @@ def interfaceconnection_add(request, pk):
 
     else:
         form = forms.InterfaceConnectionForm(device, initial={
-            'interface_a': request.GET.get('interface_a', None),
-            'site_b': request.GET.get('site_b', device.site),
-            'rack_b': request.GET.get('rack_b', None),
-            'device_b': request.GET.get('device_b', None),
-            'interface_b': request.GET.get('interface_b', None),
+            'interface_a': request.GET.get('interface_a'),
+            'site_b': request.GET.get('site_b'),
+            'rack_b': request.GET.get('rack_b'),
+            'device_b': request.GET.get('device_b'),
+            'interface_b': request.GET.get('interface_b'),
         })
 
     return render(request, 'dcim/interfaceconnection_edit.html', {
@@ -1607,7 +1640,7 @@ def interfaceconnection_delete(request, pk):
         form = forms.InterfaceConnectionDeletionForm(request.POST)
         if form.is_valid():
             interfaceconnection.delete()
-            msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
+            msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
                 interfaceconnection.interface_a.device.get_absolute_url(),
                 escape(interfaceconnection.interface_a.device),
                 escape(interfaceconnection.interface_a.name),

+ 2 - 0
netbox/extras/admin.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django import forms
 from django.contrib import admin
 from django.utils.safestring import mark_safe

+ 7 - 5
netbox/extras/api/customfields.py

@@ -1,9 +1,11 @@
-from django.contrib.contenttypes.models import ContentType
-from django.db import transaction
+from __future__ import unicode_literals
 
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
 
+from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
+
 from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
 
 
@@ -25,14 +27,14 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
 
             # Validate custom field name
             if field_name not in custom_fields:
-                raise ValidationError(u"Invalid custom field for {} objects: {}".format(content_type, field_name))
+                raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
 
             # Validate selected choice
             cf = custom_fields[field_name]
             if cf.type == CF_TYPE_SELECT:
                 valid_choices = [c.pk for c in cf.choices.all()]
                 if value not in valid_choices:
-                    raise ValidationError(u"Invalid choice ({}) for field {}".format(value, field_name))
+                    raise ValidationError("Invalid choice ({}) for field {}".format(value, field_name))
 
         # Check for missing required fields
         missing_fields = []
@@ -40,7 +42,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
             if field.required and field_name not in data:
                 missing_fields.append(field_name)
         if missing_fields:
-            raise ValidationError(u"Missing required fields: {}".format(u", ".join(missing_fields)))
+            raise ValidationError("Missing required fields: {}".format(u", ".join(missing_fields)))
 
         return data
 

+ 3 - 1
netbox/extras/api/serializers.py

@@ -1,7 +1,9 @@
-from rest_framework import serializers
+from __future__ import unicode_literals
 
 from django.core.exceptions import ObjectDoesNotExist
 
+from rest_framework import serializers
+
 from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
 from dcim.models import Device, Rack, Site
 from extras.models import (

+ 2 - 0
netbox/extras/api/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import routers
 
 from . import views

+ 2 - 0
netbox/extras/api/views.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework.decorators import detail_route
 from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 

+ 2 - 0
netbox/extras/filters.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 import django_filters
 
 from django.contrib.auth.models import User

+ 2 - 1
netbox/extras/forms.py

@@ -1,3 +1,4 @@
+from __future__ import unicode_literals
 from collections import OrderedDict
 
 from django import forms
@@ -104,7 +105,7 @@ class CustomFieldForm(forms.ModelForm):
                                                                            obj_id=self.instance.pk)
             except CustomFieldValue.DoesNotExist:
                 # Skip this field if none exists already and its value is empty
-                if self.cleaned_data[field_name] in [None, u'']:
+                if self.cleaned_data[field_name] in [None, '']:
                     continue
                 cfv = CustomFieldValue(
                     field=self.fields[field_name].model,

+ 2 - 0
netbox/extras/management/commands/run_inventory.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from getpass import getpass
 from ncclient.transport.errors import AuthenticationError
 from paramiko import AuthenticationException

+ 91 - 0
netbox/extras/migrations/0007_unicode_literals.py

@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-05-24 15:34
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import extras.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0006_add_imageattachments'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='customfield',
+            name='default',
+            field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='is_filterable',
+            field=models.BooleanField(default=True, help_text='This field can be used to filter objects.'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='label',
+            field=models.CharField(blank=True, help_text="Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='obj_type',
+            field=models.ManyToManyField(help_text='The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='required',
+            field=models.BooleanField(default=False, help_text='Determines whether this field is required when creating new objects or editing an existing object.'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='type',
+            field=models.PositiveSmallIntegerField(choices=[(100, 'Text'), (200, 'Integer'), (300, 'Boolean (true/false)'), (400, 'Date'), (500, 'URL'), (600, 'Selection')], default=100),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='weight',
+            field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form'),
+        ),
+        migrations.AlterField(
+            model_name='customfieldchoice',
+            name='weight',
+            field=models.PositiveSmallIntegerField(default=100, help_text='Higher weights appear lower in the list'),
+        ),
+        migrations.AlterField(
+            model_name='graph',
+            name='link',
+            field=models.URLField(blank=True, verbose_name='Link URL'),
+        ),
+        migrations.AlterField(
+            model_name='graph',
+            name='name',
+            field=models.CharField(max_length=100, verbose_name='Name'),
+        ),
+        migrations.AlterField(
+            model_name='graph',
+            name='source',
+            field=models.CharField(max_length=500, verbose_name='Source URL'),
+        ),
+        migrations.AlterField(
+            model_name='graph',
+            name='type',
+            field=models.PositiveSmallIntegerField(choices=[(100, 'Interface'), (200, 'Provider'), (300, 'Site')]),
+        ),
+        migrations.AlterField(
+            model_name='imageattachment',
+            name='image',
+            field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'),
+        ),
+        migrations.AlterField(
+            model_name='topologymap',
+            name='device_patterns',
+            field=models.TextField(help_text='Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
+        ),
+        migrations.AlterField(
+            model_name='useraction',
+            name='action',
+            field=models.PositiveSmallIntegerField(choices=[(1, 'created'), (7, 'bulk created'), (2, 'imported'), (3, 'modified'), (4, 'bulk edited'), (5, 'deleted'), (6, 'bulk deleted')]),
+        ),
+    ]

+ 6 - 5
netbox/extras/models.py

@@ -1,3 +1,4 @@
+from __future__ import unicode_literals
 from collections import OrderedDict
 from datetime import date
 import graphviz
@@ -175,7 +176,7 @@ class CustomFieldValue(models.Model):
         unique_together = ['field', 'obj_type', 'obj_id']
 
     def __str__(self):
-        return u'{} {}'.format(self.obj, self.field)
+        return '{} {}'.format(self.obj, self.field)
 
     @property
     def value(self):
@@ -269,7 +270,7 @@ class ExportTemplate(models.Model):
         ]
 
     def __str__(self):
-        return u'{}: {}'.format(self.content_type, self.name)
+        return '{}: {}'.format(self.content_type, self.name)
 
     def to_response(self, context_dict, filename):
         """
@@ -387,7 +388,7 @@ def image_upload(instance, filename):
     elif instance.name:
         filename = instance.name
 
-    return u'{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
+    return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
 
 
 @python_2_unicode_compatible
@@ -503,8 +504,8 @@ class UserAction(models.Model):
 
     def __str__(self):
         if self.message:
-            return u'{} {}'.format(self.user, self.message)
-        return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
+            return '{} {}'.format(self.user, self.message)
+        return '{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
 
     def icon(self):
         if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:

+ 4 - 2
netbox/extras/rpc.py

@@ -1,8 +1,10 @@
+from __future__ import unicode_literals
+import re
+import time
+
 from ncclient import manager
 import paramiko
-import re
 import xmltodict
-import time
 
 
 CONNECT_TIMEOUT = 5  # seconds

+ 2 - 0
netbox/extras/tests/test_api.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import status
 from rest_framework.test import APITestCase
 

+ 1 - 1
netbox/extras/tests/test_customfields.py

@@ -1,3 +1,4 @@
+from __future__ import unicode_literals
 from datetime import date
 
 from rest_framework import status
@@ -9,7 +10,6 @@ from django.test import TestCase
 from django.urls import reverse
 
 from dcim.models import Site
-
 from extras.models import (
     CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
     CF_TYPE_SELECT, CF_TYPE_URL,

+ 2 - 0
netbox/extras/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.conf.urls import url
 
 from extras import views

+ 2 - 0
netbox/extras/views.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.shortcuts import get_object_or_404
 

+ 2 - 0
netbox/ipam/api/serializers.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 

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

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import routers
 
 from . import views

+ 2 - 0
netbox/ipam/api/views.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework.viewsets import ModelViewSet
 
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF

+ 2 - 0
netbox/ipam/apps.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.apps import AppConfig
 
 

+ 2 - 0
netbox/ipam/fields.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from netaddr import IPNetwork
 
 from django.core.exceptions import ValidationError

+ 2 - 1
netbox/ipam/filters.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 import django_filters
 from netaddr import IPNetwork
 from netaddr.core import AddrFormatError
@@ -8,7 +10,6 @@ from dcim.models import Site, Device, Interface
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
-
 from .models import (
     Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
     VLAN_STATUS_CHOICES, VLANGroup, VRF,

+ 2 - 0
netbox/ipam/formfields.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from netaddr import IPNetwork, AddrFormatError
 
 from django import forms

+ 45 - 19
netbox/ipam/forms.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django import forms
 from django.core.exceptions import ValidationError
 from django.db.models import Count
@@ -10,7 +12,6 @@ from utilities.forms import (
     APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField,
     ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
 )
-
 from .models import (
     Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
     VLANGroup, VLAN_STATUS_CHOICES, VRF,
@@ -167,12 +168,21 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
 
 class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     site = forms.ModelChoiceField(
-        queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
+        queryset=Site.objects.all(),
+        required=False,
+        label='Site',
+        widget=forms.Select(
             attrs={'filter-for': 'vlan', 'nullable': 'true'}
         )
     )
     vlan = ChainedModelChoiceField(
-        queryset=VLAN.objects.all(), chains={'site': 'site'}, required=False, label='VLAN', widget=APISelect(
+        queryset=VLAN.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        required=False,
+        label='VLAN',
+        widget=APISelect(
             api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name'
         )
     )
@@ -270,7 +280,7 @@ def prefix_status_choices():
     status_counts = {}
     for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
 
 
 class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -321,7 +331,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
     )
     interface_rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'interface_site'},
+        chains=(
+            ('site', 'interface_site'),
+        ),
         required=False,
         label='Rack',
         widget=APISelect(
@@ -332,7 +344,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
     )
     interface_device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
-        chains={'site': 'interface_site', 'rack': 'interface_rack'},
+        chains=(
+            ('site', 'interface_site'),
+            ('rack', 'interface_rack'),
+        ),
         required=False,
         label='Device',
         widget=APISelect(
@@ -343,7 +358,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
     )
     interface = ChainedModelChoiceField(
         queryset=Interface.objects.all(),
-        chains={'device': 'interface_device'},
+        chains=(
+            ('device', 'interface_device'),
+        ),
         required=False,
         widget=APISelect(
             api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
@@ -354,34 +371,41 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         required=False,
         label='Site',
         widget=forms.Select(
-            attrs={'filter-for': 'nat_device'}
+            attrs={'filter-for': 'nat_rack'}
         )
     )
     nat_rack = ChainedModelChoiceField(
         queryset=Rack.objects.all(),
-        chains={'site': 'nat_site'},
+        chains=(
+            ('site', 'nat_site'),
+        ),
         required=False,
         label='Rack',
         widget=APISelect(
-            api_url='/api/dcim/racks/?site_id={{interface_site}}',
+            api_url='/api/dcim/racks/?site_id={{nat_site}}',
             display_field='display_name',
             attrs={'filter-for': 'nat_device', 'nullable': 'true'}
         )
     )
     nat_device = ChainedModelChoiceField(
         queryset=Device.objects.all(),
-        chains={'site': 'nat_site'},
+        chains=(
+            ('site', 'nat_site'),
+            ('rack', 'nat_rack'),
+        ),
         required=False,
         label='Device',
         widget=APISelect(
-            api_url='/api/dcim/devices/?site_id={{nat_site}}',
+            api_url='/api/dcim/devices/?site_id={{nat_site}}&rack_id={{nat_rack}}',
             display_field='display_name',
             attrs={'filter-for': 'nat_inside'}
         )
     )
     nat_inside = ChainedModelChoiceField(
         queryset=IPAddress.objects.all(),
-        chains={'interface__device': 'nat_device'},
+        chains=(
+            ('interface__device', 'nat_device'),
+        ),
         required=False,
         label='IP Address',
         widget=APISelect(
@@ -391,7 +415,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
     )
     livesearch = forms.CharField(
         required=False,
-        label='IP Address',
+        label='Search',
         widget=Livesearch(
             query_key='q',
             query_url='ipam-api:ipaddress-list',
@@ -404,8 +428,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
     class Meta:
         model = IPAddress
         fields = [
-            'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group',
-            'tenant',
+            'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
+            'nat_inside', 'tenant_group', 'tenant',
         ]
 
     def __init__(self, *args, **kwargs):
@@ -567,7 +591,7 @@ def ipaddress_status_choices():
     status_counts = {}
     for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
 
 
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -626,7 +650,9 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     )
     group = ChainedModelChoiceField(
         queryset=VLANGroup.objects.all(),
-        chains={'site': 'site'},
+        chains=(
+            ('site', 'site'),
+        ),
         required=False,
         label='Group',
         widget=APISelect(
@@ -720,7 +746,7 @@ def vlan_status_choices():
     status_counts = {}
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
         status_counts[status['status']] = status['count']
-    return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
+    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
 
 
 class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):

+ 2 - 0
netbox/ipam/lookups.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.db.models import Lookup, Transform, IntegerField
 from django.db.models.lookups import BuiltinLookup
 

+ 133 - 0
netbox/ipam/migrations/0016_unicode_literals.py

@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-05-24 15:34
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import ipam.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0015_global_vlans'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='aggregate',
+            name='family',
+            field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')]),
+        ),
+        migrations.AlterField(
+            model_name='aggregate',
+            name='rir',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name='RIR'),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='address',
+            field=ipam.fields.IPAddressField(help_text='IPv4 or IPv6 address (with mask)'),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='family',
+            field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='nat_inside',
+            field=models.OneToOneField(blank=True, help_text='The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name='NAT (Inside)'),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, verbose_name='Status'),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='vrf',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name='VRF'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='family',
+            field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='is_pool',
+            field=models.BooleanField(default=False, help_text='All IP addresses within this prefix are considered usable', verbose_name='Is a pool'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='prefix',
+            field=ipam.fields.IPNetworkField(help_text='IPv4 or IPv6 network with mask'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='role',
+            field=models.ForeignKey(blank=True, help_text='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, 'Container'), (1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, help_text='Operational status of this prefix', verbose_name='Status'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='vlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name='VLAN'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='vrf',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name='VRF'),
+        ),
+        migrations.AlterField(
+            model_name='rir',
+            name='is_private',
+            field=models.BooleanField(default=False, help_text='IP space managed by this RIR is considered private', verbose_name='Private'),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='device',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='ipaddresses',
+            field=models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name='IP addresses'),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='port',
+            field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name='Port number'),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='protocol',
+            field=models.PositiveSmallIntegerField(choices=[(6, 'TCP'), (17, 'UDP')]),
+        ),
+        migrations.AlterField(
+            model_name='vlan',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, verbose_name='Status'),
+        ),
+        migrations.AlterField(
+            model_name='vlan',
+            name='vid',
+            field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name='ID'),
+        ),
+        migrations.AlterField(
+            model_name='vrf',
+            name='enforce_unique',
+            field=models.BooleanField(default=True, help_text='Prevent duplicate prefixes/IP addresses within this VRF', verbose_name='Enforce unique space'),
+        ),
+        migrations.AlterField(
+            model_name='vrf',
+            name='rd',
+            field=models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher'),
+        ),
+    ]

+ 5 - 4
netbox/ipam/models.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from netaddr import IPNetwork, cidr_merge
 
 from django.conf import settings
@@ -15,7 +17,6 @@ from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.sql import NullsFirstQuerySet
 from utilities.utils import csv_format
-
 from .fields import IPNetworkField, IPAddressField
 
 
@@ -499,7 +500,7 @@ class VLANGroup(models.Model):
     def __str__(self):
         if self.site is None:
             return self.name
-        return u'{} - {}'.format(self.site.name, self.name)
+        return '{} - {}'.format(self.site.name, self.name)
 
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@@ -566,7 +567,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
     @property
     def display_name(self):
         if self.vid and self.name:
-            return u"{} ({})".format(self.vid, self.name)
+            return "{} ({})".format(self.vid, self.name)
         return None
 
     def get_status_class(self):
@@ -593,4 +594,4 @@ class Service(CreatedUpdatedModel):
         unique_together = ['device', 'protocol', 'port']
 
     def __str__(self):
-        return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
+        return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())

+ 2 - 1
netbox/ipam/tables.py

@@ -1,8 +1,9 @@
+from __future__ import unicode_literals
+
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
 from utilities.tables import BaseTable, SearchTable, ToggleColumn
-
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 

+ 2 - 1
netbox/ipam/tests/test_api.py

@@ -1,5 +1,6 @@
-from netaddr import IPNetwork
+from __future__ import unicode_literals
 
+from netaddr import IPNetwork
 from rest_framework import status
 from rest_framework.test import APITestCase
 

+ 3 - 1
netbox/ipam/tests/test_models.py

@@ -1,9 +1,11 @@
+from __future__ import unicode_literals
+
 import netaddr
 
+from django.core.exceptions import ValidationError
 from django.test import TestCase, override_settings
 
 from ipam.models import IPAddress, Prefix, VRF
-from django.core.exceptions import ValidationError
 
 
 class TestPrefix(TestCase):

+ 8 - 6
netbox/ipam/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.conf.urls import url
 
 from . import views
@@ -12,7 +14,7 @@ urlpatterns = [
     url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
     url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
     url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
-    url(r'^vrfs/(?P<pk>\d+)/$', views.vrf, name='vrf'),
+    url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
     url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
     url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
 
@@ -28,7 +30,7 @@ urlpatterns = [
     url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
     url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
-    url(r'^aggregates/(?P<pk>\d+)/$', views.aggregate, name='aggregate'),
+    url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
     url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
     url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
 
@@ -44,10 +46,10 @@ urlpatterns = [
     url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
     url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
     url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
-    url(r'^prefixes/(?P<pk>\d+)/$', views.prefix, name='prefix'),
+    url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
     url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
     url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
-    url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'),
+    url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
 
     # IP addresses
     url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
@@ -56,7 +58,7 @@ urlpatterns = [
     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/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
-    url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
+    url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
 
@@ -72,7 +74,7 @@ urlpatterns = [
     url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
     url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
-    url(r'^vlans/(?P<pk>\d+)/$', views.vlan, name='vlan'),
+    url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
     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'),
 

+ 215 - 165
netbox/ipam/views.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django_tables2 import RequestConfig
 import netaddr
 
@@ -6,13 +8,13 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.db.models import Count, Q
 from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
+from django.views.generic import View
 
 from dcim.models import Device
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
     BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
-
 from . import filters, forms, tables
 from .models import (
     Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
@@ -96,18 +98,20 @@ class VRFListView(ObjectListView):
     template_name = 'ipam/vrf_list.html'
 
 
-def vrf(request, pk):
+class VRFView(View):
 
-    vrf = get_object_or_404(VRF.objects.all(), pk=pk)
-    prefix_table = tables.PrefixBriefTable(
-        list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
-    )
-    prefix_table.exclude = ('vrf',)
+    def get(self, request, pk):
 
-    return render(request, 'ipam/vrf.html', {
-        'vrf': vrf,
-        'prefix_table': prefix_table,
-    })
+        vrf = get_object_or_404(VRF.objects.all(), pk=pk)
+        prefix_table = tables.PrefixBriefTable(
+            list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
+        )
+        prefix_table.exclude = ('vrf',)
+
+        return render(request, 'ipam/vrf.html', {
+            'vrf': vrf,
+            'prefix_table': prefix_table,
+        })
 
 
 class VRFEditView(PermissionRequiredMixin, ObjectEditView):
@@ -281,37 +285,44 @@ class AggregateListView(ObjectListView):
         }
 
 
-def aggregate(request, pk):
+class AggregateView(View):
 
-    aggregate = get_object_or_404(Aggregate, pk=pk)
+    def get(self, request, pk):
 
-    # Find all child prefixes contained by this aggregate
-    child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\
-        .select_related('site', 'role').annotate_depth(limit=0)
-    child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
+        aggregate = get_object_or_404(Aggregate, pk=pk)
 
-    prefix_table = tables.PrefixTable(child_prefixes)
-    if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
-        prefix_table.base_columns['pk'].visible = True
+        # Find all child prefixes contained by this aggregate
+        child_prefixes = Prefix.objects.filter(
+            prefix__net_contained_or_equal=str(aggregate.prefix)
+        ).select_related(
+            'site', 'role'
+        ).annotate_depth(
+            limit=0
+        )
+        child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
 
-    paginate = {
-        'klass': EnhancedPaginator,
-        'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
-    }
-    RequestConfig(request, paginate).configure(prefix_table)
+        prefix_table = tables.PrefixTable(child_prefixes)
+        if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
+            prefix_table.base_columns['pk'].visible = True
 
-    # Compile permissions list for rendering the object table
-    permissions = {
-        'add': request.user.has_perm('ipam.add_prefix'),
-        'change': request.user.has_perm('ipam.change_prefix'),
-        'delete': request.user.has_perm('ipam.delete_prefix'),
-    }
+        paginate = {
+            'klass': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(prefix_table)
 
-    return render(request, 'ipam/aggregate.html', {
-        'aggregate': aggregate,
-        'prefix_table': prefix_table,
-        'permissions': permissions,
-    })
+        # Compile permissions list for rendering the object table
+        permissions = {
+            'add': request.user.has_perm('ipam.add_prefix'),
+            'change': request.user.has_perm('ipam.change_prefix'),
+            'delete': request.user.has_perm('ipam.delete_prefix'),
+        }
+
+        return render(request, 'ipam/aggregate.html', {
+            'aggregate': aggregate,
+            'prefix_table': prefix_table,
+            'permissions': permissions,
+        })
 
 
 class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
@@ -394,66 +405,120 @@ class PrefixListView(ObjectListView):
         return self.queryset.annotate_depth(limit=limit)
 
 
-def prefix(request, pk):
-
-    prefix = get_object_or_404(Prefix.objects.select_related(
-        'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
-    ), pk=pk)
-
-    try:
-        aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
-    except Aggregate.DoesNotExist:
-        aggregate = None
-
-    # Count child IP addresses
-    ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\
-        .count()
-
-    # Parent prefixes table
-    parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\
-        .filter(prefix__net_contains=str(prefix.prefix))\
-        .select_related('site', 'role').annotate_depth()
-    parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
-    parent_prefix_table.exclude = ('vrf',)
-
-    # Duplicate prefixes table
-    duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
-        .select_related('site', 'role')
-    duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
-    duplicate_prefix_table.exclude = ('vrf',)
-
-    # Child prefixes table
-    child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\
-        .select_related('site', 'role').annotate_depth(limit=0)
-    if child_prefixes:
-        child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
-    child_prefix_table = tables.PrefixTable(child_prefixes)
-    if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
-        child_prefix_table.base_columns['pk'].visible = True
-
-    paginate = {
-        'klass': EnhancedPaginator,
-        'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
-    }
-    RequestConfig(request, paginate).configure(child_prefix_table)
-
-    # Compile permissions list for rendering the object table
-    permissions = {
-        'add': request.user.has_perm('ipam.add_prefix'),
-        'change': request.user.has_perm('ipam.change_prefix'),
-        'delete': request.user.has_perm('ipam.delete_prefix'),
-    }
-
-    return render(request, 'ipam/prefix.html', {
-        'prefix': prefix,
-        'aggregate': aggregate,
-        'ipaddress_count': ipaddress_count,
-        'parent_prefix_table': parent_prefix_table,
-        'child_prefix_table': child_prefix_table,
-        'duplicate_prefix_table': duplicate_prefix_table,
-        'permissions': permissions,
-        'return_url': prefix.get_absolute_url(),
-    })
+class PrefixView(View):
+
+    def get(self, request, pk):
+
+        prefix = get_object_or_404(Prefix.objects.select_related(
+            'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
+        ), pk=pk)
+
+        try:
+            aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
+        except Aggregate.DoesNotExist:
+            aggregate = None
+
+        # Count child IP addresses
+        ipaddress_count = IPAddress.objects.filter(
+            vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix)
+        ).count()
+
+        # Parent prefixes table
+        parent_prefixes = Prefix.objects.filter(
+            Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
+        ).filter(
+            prefix__net_contains=str(prefix.prefix)
+        ).select_related(
+            'site', 'role'
+        ).annotate_depth()
+        parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
+        parent_prefix_table.exclude = ('vrf',)
+
+        # Duplicate prefixes table
+        duplicate_prefixes = Prefix.objects.filter(
+            vrf=prefix.vrf, prefix=str(prefix.prefix)
+        ).exclude(
+            pk=prefix.pk
+        ).select_related(
+            'site', 'role'
+        )
+        duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
+        duplicate_prefix_table.exclude = ('vrf',)
+
+        # Child prefixes table
+        child_prefixes = Prefix.objects.filter(
+            vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
+        ).select_related(
+            'site', 'role'
+        ).annotate_depth(limit=0)
+        if child_prefixes:
+            child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
+        child_prefix_table = tables.PrefixTable(child_prefixes)
+        if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
+            child_prefix_table.base_columns['pk'].visible = True
+
+        paginate = {
+            'klass': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(child_prefix_table)
+
+        # Compile permissions list for rendering the object table
+        permissions = {
+            'add': request.user.has_perm('ipam.add_prefix'),
+            'change': request.user.has_perm('ipam.change_prefix'),
+            'delete': request.user.has_perm('ipam.delete_prefix'),
+        }
+
+        return render(request, 'ipam/prefix.html', {
+            'prefix': prefix,
+            'aggregate': aggregate,
+            'ipaddress_count': ipaddress_count,
+            'parent_prefix_table': parent_prefix_table,
+            'child_prefix_table': child_prefix_table,
+            'duplicate_prefix_table': duplicate_prefix_table,
+            'permissions': permissions,
+            'return_url': prefix.get_absolute_url(),
+        })
+
+
+class PrefixIPAddressesView(View):
+
+    def get(self, request, pk):
+
+        prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
+
+        # Find all IPAddresses belonging to this Prefix
+        ipaddresses = IPAddress.objects.filter(
+            vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix)
+        ).select_related(
+            'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
+        )
+        ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
+
+        ip_table = tables.IPAddressTable(ipaddresses)
+        if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
+            ip_table.base_columns['pk'].visible = True
+
+        paginate = {
+            'klass': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(ip_table)
+
+        # Compile permissions list for rendering the object table
+        permissions = {
+            'add': request.user.has_perm('ipam.add_ipaddress'),
+            'change': request.user.has_perm('ipam.change_ipaddress'),
+            'delete': request.user.has_perm('ipam.delete_ipaddress'),
+        }
+
+        return render(request, 'ipam/prefix_ipaddresses.html', {
+            'prefix': prefix,
+            'ip_table': ip_table,
+            'permissions': permissions,
+            'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix),
+        })
 
 
 class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
@@ -495,40 +560,6 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'ipam:prefix_list'
 
 
-def prefix_ipaddresses(request, pk):
-
-    prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
-
-    # Find all IPAddresses belonging to this Prefix
-    ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\
-        .select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
-    ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
-
-    ip_table = tables.IPAddressTable(ipaddresses)
-    if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
-        ip_table.base_columns['pk'].visible = True
-
-    paginate = {
-        'klass': EnhancedPaginator,
-        'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
-    }
-    RequestConfig(request, paginate).configure(ip_table)
-
-    # Compile permissions list for rendering the object table
-    permissions = {
-        'add': request.user.has_perm('ipam.add_ipaddress'),
-        'change': request.user.has_perm('ipam.change_ipaddress'),
-        'delete': request.user.has_perm('ipam.delete_ipaddress'),
-    }
-
-    return render(request, 'ipam/prefix_ipaddresses.html', {
-        'prefix': prefix,
-        'ip_table': ip_table,
-        'permissions': permissions,
-        'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix),
-    })
-
-
 #
 # IP addresses
 #
@@ -541,32 +572,47 @@ class IPAddressListView(ObjectListView):
     template_name = 'ipam/ipaddress_list.html'
 
 
-def ipaddress(request, pk):
-
-    ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
-
-    # Parent prefixes table
-    parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
-        .select_related('site', 'role')
-    parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
-    parent_prefixes_table.exclude = ('vrf',)
-
-    # Duplicate IPs table
-    duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
-        .exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
-    duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
-
-    # Related IP table
-    related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
-        .filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
-    related_ips_table = tables.IPAddressBriefTable(list(related_ips))
-
-    return render(request, 'ipam/ipaddress.html', {
-        'ipaddress': ipaddress,
-        'parent_prefixes_table': parent_prefixes_table,
-        'duplicate_ips_table': duplicate_ips_table,
-        'related_ips_table': related_ips_table,
-    })
+class IPAddressView(View):
+
+    def get(self, request, pk):
+
+        ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
+
+        # Parent prefixes table
+        parent_prefixes = Prefix.objects.filter(
+            vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
+        ).select_related(
+            'site', 'role'
+        )
+        parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
+        parent_prefixes_table.exclude = ('vrf',)
+
+        # Duplicate IPs table
+        duplicate_ips = IPAddress.objects.filter(
+            vrf=ipaddress.vrf, address=str(ipaddress.address)
+        ).exclude(
+            pk=ipaddress.pk
+        ).select_related(
+            'interface__device', 'nat_inside'
+        )
+        duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
+
+        # Related IP table
+        related_ips = IPAddress.objects.select_related(
+            'interface__device'
+        ).exclude(
+            address=str(ipaddress.address)
+        ).filter(
+            vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
+        )
+        related_ips_table = tables.IPAddressBriefTable(list(related_ips))
+
+        return render(request, 'ipam/ipaddress.html', {
+            'ipaddress': ipaddress,
+            'parent_prefixes_table': parent_prefixes_table,
+            'duplicate_ips_table': duplicate_ips_table,
+            'related_ips_table': related_ips_table,
+        })
 
 
 class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
@@ -669,17 +715,21 @@ class VLANListView(ObjectListView):
     template_name = 'ipam/vlan_list.html'
 
 
-def vlan(request, pk):
+class VLANView(View):
 
-    vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk)
-    prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
-    prefix_table = tables.PrefixBriefTable(list(prefixes))
-    prefix_table.exclude = ('vlan',)
+    def get(self, request, pk):
 
-    return render(request, 'ipam/vlan.html', {
-        'vlan': vlan,
-        'prefix_table': prefix_table,
-    })
+        vlan = get_object_or_404(VLAN.objects.select_related(
+            'site__region', 'tenant__group', 'role'
+        ), pk=pk)
+        prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
+        prefix_table = tables.PrefixBriefTable(list(prefixes))
+        prefix_table.exclude = ('vlan',)
+
+        return render(request, 'ipam/vlan.html', {
+            'vlan': vlan,
+            'prefix_table': prefix_table,
+        })
 
 
 class VLANEditView(PermissionRequiredMixin, ObjectEditView):

+ 2 - 0
netbox/netbox/forms.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django import forms
 
 from utilities.forms import BootstrapMixin

+ 3 - 2
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
 
 
-VERSION = '2.0.3'
+VERSION = '2.0.4'
 
 # Import local configuration
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
@@ -112,6 +112,7 @@ INSTALLED_APPS = (
     'django.contrib.humanize',
     'corsheaders',
     'debug_toolbar',
+    'django_filters',
     'django_tables2',
     'mptt',
     'rest_framework',
@@ -180,8 +181,8 @@ STATICFILES_DIRS = (
 )
 
 # Media
-MEDIA_URL = '/media/'
 MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+MEDIA_URL = '/{}media/'.format(BASE_PATH)
 
 # Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
 DATA_UPLOAD_MAX_NUMBER_FIELDS = None

+ 7 - 5
netbox/netbox/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework_swagger.views import get_swagger_view
 
 from django.conf import settings
@@ -5,8 +7,8 @@ from django.conf.urls import include, url
 from django.contrib import admin
 from django.views.static import serve
 
-from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
-from users.views import login, logout
+from netbox.views import APIRootView, handle_500, HomeView, SearchView, trigger_500
+from users.views import LoginView, LogoutView
 
 
 handler500 = handle_500
@@ -15,12 +17,12 @@ swagger_view = get_swagger_view(title='NetBox API')
 _patterns = [
 
     # Base views
-    url(r'^$', home, name='home'),
+    url(r'^$', HomeView.as_view(), name='home'),
     url(r'^search/$', SearchView.as_view(), name='search'),
 
     # Login/logout
-    url(r'^login/$', login, name='login'),
-    url(r'^logout/$', logout, name='logout'),
+    url(r'^login/$', LoginView.as_view(), name='login'),
+    url(r'^logout/$', LogoutView.as_view(), name='logout'),
 
     # Apps
     url(r'^circuits/', include('circuits.urls')),

+ 38 - 33
netbox/netbox/views.py

@@ -1,3 +1,4 @@
+from __future__ import unicode_literals
 from collections import OrderedDict
 import sys
 
@@ -115,43 +116,46 @@ SEARCH_TYPES = OrderedDict((
 ))
 
 
-def home(request):
+class HomeView(View):
+    template_name = 'home.html'
 
-    stats = {
+    def get(self, request):
+
+        stats = {
 
-        # Organization
-        'site_count': Site.objects.count(),
-        'tenant_count': Tenant.objects.count(),
+            # Organization
+            'site_count': Site.objects.count(),
+            'tenant_count': Tenant.objects.count(),
 
-        # DCIM
-        'rack_count': Rack.objects.count(),
-        'device_count': Device.objects.count(),
-        'interface_connections_count': InterfaceConnection.objects.count(),
-        'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
-        'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
+            # DCIM
+            'rack_count': Rack.objects.count(),
+            'device_count': Device.objects.count(),
+            'interface_connections_count': InterfaceConnection.objects.count(),
+            'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
+            'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
 
-        # IPAM
-        'vrf_count': VRF.objects.count(),
-        'aggregate_count': Aggregate.objects.count(),
-        'prefix_count': Prefix.objects.count(),
-        'ipaddress_count': IPAddress.objects.count(),
-        'vlan_count': VLAN.objects.count(),
+            # IPAM
+            'vrf_count': VRF.objects.count(),
+            'aggregate_count': Aggregate.objects.count(),
+            'prefix_count': Prefix.objects.count(),
+            'ipaddress_count': IPAddress.objects.count(),
+            'vlan_count': VLAN.objects.count(),
 
-        # Circuits
-        'provider_count': Provider.objects.count(),
-        'circuit_count': Circuit.objects.count(),
+            # Circuits
+            'provider_count': Provider.objects.count(),
+            'circuit_count': Circuit.objects.count(),
 
-        # Secrets
-        'secret_count': Secret.objects.count(),
+            # Secrets
+            'secret_count': Secret.objects.count(),
 
-    }
+        }
 
-    return render(request, 'home.html', {
-        'search_form': SearchForm(),
-        'stats': stats,
-        'topology_maps': TopologyMap.objects.filter(site__isnull=True),
-        'recent_activity': UserAction.objects.select_related('user')[:50]
-    })
+        return render(request, self.template_name, {
+            'search_form': SearchForm(),
+            'stats': stats,
+            'topology_maps': TopologyMap.objects.filter(site__isnull=True),
+            'recent_activity': UserAction.objects.select_related('user')[:50]
+        })
 
 
 class SearchView(View):
@@ -192,7 +196,7 @@ class SearchView(View):
                     results.append({
                         'name': queryset.model._meta.verbose_name_plural,
                         'table': table,
-                        'url': u'{}?q={}'.format(reverse(url), form.cleaned_data['q'])
+                        'url': '{}?q={}'.format(reverse(url), form.cleaned_data['q'])
                     })
 
         return render(request, 'search.html', {
@@ -206,7 +210,7 @@ class APIRootView(APIView):
     exclude_from_schema = True
 
     def get_view_name(self):
-        return u"API Root"
+        return "API Root"
 
     def get(self, request, format=None):
 
@@ -235,5 +239,6 @@ def trigger_500(request):
     """
     Hot-wired method of triggering a server error to test reporting
     """
-    raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
-                    "person you are.")
+    raise Exception(
+        "Congratulations, you've triggered an exception! Go tell all your friends what an exceptional person you are."
+    )

+ 1 - 0
netbox/netbox/wsgi.py

@@ -11,6 +11,7 @@ import os
 
 from django.core.wsgi import get_wsgi_application
 
+
 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
 
 application = get_wsgi_application()

+ 6 - 4
netbox/secrets/admin.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.contrib import admin, messages
 from django.shortcuts import redirect, render
 
@@ -34,8 +36,8 @@ class UserKeyAdmin(admin.ModelAdmin):
         try:
             my_userkey = UserKey.objects.get(user=request.user)
         except UserKey.DoesNotExist:
-            messages.error(request, u"You do not have an active User Key.")
-            return redirect('/admin/secrets/userkey/')
+            messages.error(request, "You do not have an active User Key.")
+            return redirect('admin:secrets_userkey_changelist')
 
         if 'activate' in request.POST:
             form = ActivateUserKeyForm(request.POST)
@@ -44,9 +46,9 @@ class UserKeyAdmin(admin.ModelAdmin):
                     master_key = my_userkey.get_master_key(form.cleaned_data['secret_key'])
                     for uk in form.cleaned_data['_selected_action']:
                         uk.activate(master_key)
-                    return redirect('/admin/secrets/userkey/')
+                    return redirect('admin:secrets_userkey_changelist')
                 except ValueError:
-                    messages.error(request, u"Invalid private key provided. Unable to retrieve master key.")
+                    messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
         else:
             form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
 

+ 2 - 0
netbox/secrets/api/serializers.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 

+ 2 - 0
netbox/secrets/api/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import routers
 
 from . import views

+ 4 - 3
netbox/secrets/api/views.py

@@ -1,13 +1,14 @@
+from __future__ import unicode_literals
 import base64
-from Crypto.PublicKey import RSA
-
-from django.http import HttpResponseBadRequest
 
+from Crypto.PublicKey import RSA
 from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.viewsets import ModelViewSet, ViewSet
 
+from django.http import HttpResponseBadRequest
+
 from secrets import filters
 from secrets.exceptions import InvalidKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey

+ 4 - 2
netbox/secrets/decorators.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.contrib import messages
 from django.shortcuts import redirect
 
@@ -14,10 +16,10 @@ def userkey_required():
             try:
                 uk = UserKey.objects.get(user=request.user)
             except UserKey.DoesNotExist:
-                messages.warning(request, u"This operation requires an active user key, but you don't have one.")
+                messages.warning(request, "This operation requires an active user key, but you don't have one.")
                 return redirect('user:userkey')
             if not uk.is_active():
-                messages.warning(request, u"This operation is not available. Your user key has not been activated.")
+                messages.warning(request, "This operation is not available. Your user key has not been activated.")
                 return redirect('user:userkey')
             return view(request, *args, **kwargs)
         return wrapped_view

+ 3 - 0
netbox/secrets/exceptions.py

@@ -1,3 +1,6 @@
+from __future__ import unicode_literals
+
+
 class InvalidKey(Exception):
     """
     Raised when a provided key is invalid.

+ 2 - 0
netbox/secrets/filters.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 import django_filters
 
 from django.db.models import Q

+ 2 - 1
netbox/secrets/forms.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from Crypto.Cipher import PKCS1_OAEP
 from Crypto.PublicKey import RSA
 
@@ -6,7 +8,6 @@ from django.db.models import Count
 
 from dcim.models import Device
 from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
-
 from .models import Secret, SecretRole, UserKey
 
 

+ 2 - 0
netbox/secrets/hashers.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.contrib.auth.hashers import PBKDF2PasswordHasher
 
 

+ 20 - 0
netbox/secrets/migrations/0003_unicode_literals.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-05-24 15:34
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('secrets', '0002_userkey_add_session_key'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='userkey',
+            name='public_key',
+            field=models.TextField(verbose_name='RSA public key'),
+        ),
+    ]

+ 4 - 3
netbox/secrets/models.py

@@ -1,4 +1,6 @@
+from __future__ import unicode_literals
 import os
+
 from Crypto.Cipher import AES, PKCS1_OAEP, XOR
 from Crypto.PublicKey import RSA
 
@@ -12,7 +14,6 @@ from django.utils.encoding import force_bytes, python_2_unicode_compatible
 
 from dcim.models import Device
 from utilities.models import CreatedUpdatedModel
-
 from .exceptions import InvalidKey
 from .hashers import SecretValidationHasher
 
@@ -301,8 +302,8 @@ class Secret(CreatedUpdatedModel):
 
     def __str__(self):
         if self.role and self.device:
-            return u'{} for {}'.format(self.role, self.device)
-        return u'Secret'
+            return '{} for {}'.format(self.role, self.device)
+        return 'Secret'
 
     def get_absolute_url(self):
         return reverse('secrets:secret', args=[self.pk])

+ 5 - 3
netbox/secrets/tables.py

@@ -1,5 +1,6 @@
+from __future__ import unicode_literals
+
 import django_tables2 as tables
-from django_tables2.utils import Accessor
 
 from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
@@ -22,8 +23,9 @@ class SecretRoleTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     secret_count = tables.Column(verbose_name='Secrets')
     slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
-                                    verbose_name='')
+    actions = tables.TemplateColumn(
+        template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = SecretRole

+ 2 - 0
netbox/secrets/templatetags/secret_helpers.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django import template
 
 

+ 2 - 0
netbox/secrets/tests/test_api.py

@@ -1,4 +1,6 @@
+from __future__ import unicode_literals
 import base64
+
 from rest_framework import status
 from rest_framework.test import APITestCase
 

+ 2 - 0
netbox/secrets/tests/test_models.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from Crypto.PublicKey import RSA
 
 from django.conf import settings

+ 3 - 1
netbox/secrets/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.conf.urls import url
 
 from . import views
@@ -17,7 +19,7 @@ urlpatterns = [
     url(r'^secrets/import/$', views.secret_import, name='secret_import'),
     url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
     url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
-    url(r'^secrets/(?P<pk>\d+)/$', views.secret, name='secret'),
+    url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
     url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
     url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'),
 

+ 14 - 11
netbox/secrets/views.py

@@ -1,3 +1,4 @@
+from __future__ import unicode_literals
 import base64
 
 from django.contrib import messages
@@ -8,10 +9,10 @@ from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils.decorators import method_decorator
+from django.views.generic import View
 
 from dcim.models import Device
 from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
-
 from . import filters, forms, tables
 from .decorators import userkey_required
 from .models import SecretRole, Secret, SessionKey
@@ -65,14 +66,16 @@ class SecretListView(ObjectListView):
     template_name = 'secrets/secret_list.html'
 
 
-@login_required
-def secret(request, pk):
+@method_decorator(login_required, name='dispatch')
+class SecretView(View):
 
-    secret = get_object_or_404(Secret, pk=pk)
+    def get(self, request, pk):
 
-    return render(request, 'secrets/secret.html', {
-        'secret': secret,
-    })
+        secret = get_object_or_404(Secret, pk=pk)
+
+        return render(request, 'secrets/secret.html', {
+            'secret': secret,
+        })
 
 
 @permission_required('secrets.add_secret')
@@ -107,7 +110,7 @@ def secret_add(request, pk):
                     secret.plaintext = str(form.cleaned_data['plaintext'])
                     secret.encrypt(master_key)
                     secret.save()
-                    messages.success(request, u"Added new secret: {}.".format(secret))
+                    messages.success(request, "Added new secret: {}.".format(secret))
                     if '_addanother' in request.POST:
                         return redirect('dcim:device_addsecret', pk=device.pk)
                     else:
@@ -151,7 +154,7 @@ def secret_edit(request, pk):
                     secret.plaintext = str(form.cleaned_data['plaintext'])
                     secret.encrypt(master_key)
                     secret.save()
-                    messages.success(request, u"Modified secret {}.".format(secret))
+                    messages.success(request, "Modified secret {}.".format(secret))
                     return redirect('secrets:secret', pk=secret.pk)
                 else:
                     form.add_error(None, "Invalid session key. Unable to encrypt secret data.")
@@ -163,7 +166,7 @@ def secret_edit(request, pk):
             # If no new plaintext was specified, a session key is not needed.
             else:
                 secret = form.save()
-                messages.success(request, u"Modified secret {}.".format(secret))
+                messages.success(request, "Modified secret {}.".format(secret))
                 return redirect('secrets:secret', pk=secret.pk)
 
     else:
@@ -217,7 +220,7 @@ def secret_import(request):
                             new_secrets.append(secret)
 
                     table = tables.SecretTable(new_secrets)
-                    messages.success(request, u"Imported {} new secrets.".format(len(new_secrets)))
+                    messages.success(request, "Imported {} new secrets.".format(len(new_secrets)))
 
                     return render(request, 'import_success.html', {
                         'table': table,

+ 24 - 21
netbox/templates/500.html

@@ -1,37 +1,40 @@
+{% load static from staticfiles %}
 <!DOCTYPE html>
 <html lang="en">
 
 <head>
 	<title>Server Error</title>
-	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
-    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css">
+	<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
+    <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
 </head>
 
 <body>
-	<div class="row">
-        <div class="col-md-4 col-md-offset-4">
-            <div class="panel panel-danger" style="margin-top: 200px">
-                <div class="panel-heading">
-                    <strong>
-                        <i class="fa fa-warning"></i>
-                        Server Error
-                    </strong>
-                </div>
-                <div class="panel-body">
-                    <p>There was a problem with your request. This error has been logged and administrative staff have
-                    been notified. Please return to the home page and try again.</p>
-                    <p>If you are responsible for this installation, please consider
-                    <a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>. Additional
-                    information is provided below:</p>
-                    <pre><strong>{{ exception }}</strong><br />
+    <div class="container-fluid">
+        <div class="row">
+            <div class="col-md-4 col-md-offset-4">
+                <div class="panel panel-danger" style="margin-top: 200px">
+                    <div class="panel-heading">
+                        <strong>
+                            <i class="fa fa-warning"></i>
+                            Server Error
+                        </strong>
+                    </div>
+                    <div class="panel-body">
+                        <p>There was a problem with your request. This error has been logged and administrative staff have
+                        been notified. Please return to the home page and try again.</p>
+                        <p>If you are responsible for this installation, please consider
+                        <a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>. Additional
+                        information is provided below:</p>
+<pre><strong>{{ exception }}</strong><br />
 {{ error }}</pre>
-                    <div class="text-right">
-                        <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
+                        <div class="text-right">
+                            <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
+                        </div>
                     </div>
                 </div>
             </div>
         </div>
-	</div>
+    </div>
 </body>
 
 </html>

+ 2 - 17
netbox/templates/circuits/circuittermination_edit.html

@@ -45,23 +45,8 @@
                             </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.rack %}
+                        {% render_field form.device %}
                         {% render_field form.interface %}
                     </div>
                 </div>

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

@@ -32,12 +32,7 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
-                            <div class="form-group">
-                                <label class="col-md-3 control-label">Site</label>
-                                <div class="col-md-9">
-                                    <p class="form-control-static">{{ consoleport.device.site }}</p>
-                                </div>
-                            </div>
+                            {% render_field form.site %}
                             {% render_field form.rack %}
                             {% render_field form.console_server %}
                         </div>

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

@@ -32,12 +32,7 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
-                            <div class="form-group">
-                                <label class="col-md-3 control-label">Site</label>
-                                <div class="col-md-9">
-                                    <p class="form-control-static">{{ consoleserverport.device.site }}</p>
-                                </div>
-                            </div>
+                            {% render_field form.site %}
                             {% render_field form.rack %}
                             {% render_field form.device %}
                         </div>

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

@@ -69,6 +69,11 @@
 					<td>Unique alphanumeric tag (optional)</td>
 					<td>ABC123456</td>
 				</tr>
+                <tr>
+                    <td>Status</td>
+                    <td>Current status</td>
+                    <td>Active</td>
+                </tr>
 				<tr>
 					<td>Parent device</td>
 					<td>Parent device</td>
@@ -82,7 +87,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Server101,Slot4</pre>
+		<pre>Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Active,Server101,Slot4</pre>
 	</div>
 </div>
 {% endblock %}

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

@@ -32,12 +32,7 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
-                            <div class="form-group">
-                                <label class="col-md-3 control-label">Site</label>
-                                <div class="col-md-9">
-                                    <p class="form-control-static">{{ poweroutlet.device.site }}</p>
-                                </div>
-                            </div>
+                            {% render_field form.site %}
                             {% render_field form.rack %}
                             {% render_field form.device %}
                         </div>

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

@@ -32,12 +32,7 @@
                             {% render_field form.livesearch %}
                         </div>
                         <div class="tab-pane" id="select">
-                            <div class="form-group">
-                                <label class="col-md-3 control-label">Site</label>
-                                <div class="col-md-9">
-                                    <p class="form-control-static">{{ powerport.device.site }}</p>
-                                </div>
-                            </div>
+                            {% render_field form.site %}
                             {% render_field form.rack %}
                             {% render_field form.pdu %}
                         </div>

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

@@ -6,7 +6,7 @@
             <tr>
                 {% for column in table.columns %}
                     {% if column.orderable %}
-                        <th {{ column.attrs.th.as_html }}><a href="{% querystring page=column.order_by_alias.next %}">{{ column.header }}</a></th>
+                        <th {{ column.attrs.th.as_html }}><a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a></th>
                     {% else %}
                         <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
                     {% endif %}

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

@@ -47,6 +47,7 @@
             <div class="tab-content">
                 <div class="tab-pane active" id="select">
                     {% render_field form.nat_site %}
+                    {% render_field form.nat_rack %}
                     {% render_field form.nat_device %}
                 </div>
                 <div class="tab-pane" id="search">

+ 2 - 0
netbox/tenancy/api/serializers.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import serializers
 
 from extras.api.customfields import CustomFieldModelSerializer

+ 2 - 0
netbox/tenancy/api/urls.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from rest_framework import routers
 
 from . import views

+ 3 - 2
netbox/tenancy/api/views.py

@@ -1,9 +1,10 @@
+from __future__ import unicode_literals
+
 from rest_framework.viewsets import ModelViewSet
 
+from extras.api.views import CustomFieldModelViewSet
 from tenancy.models import Tenant, TenantGroup
 from tenancy.filters import TenantFilter
-
-from extras.api.views import CustomFieldModelViewSet
 from utilities.api import WritableSerializerMixin
 from . import serializers
 

+ 2 - 0
netbox/tenancy/apps.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django.apps import AppConfig
 
 

+ 2 - 0
netbox/tenancy/filters.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 import django_filters
 
 from django.db.models import Q

+ 5 - 1
netbox/tenancy/forms.py

@@ -1,3 +1,5 @@
+from __future__ import unicode_literals
+
 from django import forms
 from django.db.models import Count
 
@@ -79,7 +81,9 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
     )
     tenant = ChainedModelChoiceField(
         queryset=Tenant.objects.all(),
-        chains={'group': 'tenant_group'},
+        chains=(
+            ('group', 'tenant_group'),
+        ),
         required=False,
         widget=APISelect(
             api_url='/api/tenancy/tenants/?group_id={{tenant_group}}'

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor