Browse Source

Merge pull request #1042 from digitalocean/v2-develop

Release v2.0 Beta 2
Jeremy Stretch 8 years ago
parent
commit
1a34830f0e
100 changed files with 4014 additions and 1266 deletions
  1. 0 4
      README.md
  2. 1 1
      docs/api/authentication.md
  3. 1 1
      docs/api/overview.md
  4. 0 4
      docs/data-model/dcim.md
  5. 7 0
      docs/data-model/extras.md
  6. 0 29
      netbox/circuits/admin.py
  7. 2 2
      netbox/circuits/forms.py
  8. 35 17
      netbox/circuits/tables.py
  9. 0 212
      netbox/dcim/admin.py
  10. 3 1
      netbox/dcim/api/urls.py
  11. 19 5
      netbox/dcim/api/views.py
  12. 58 13
      netbox/dcim/filters.py
  13. 30 12
      netbox/dcim/forms.py
  14. 8 5
      netbox/dcim/models.py
  15. 91 25
      netbox/dcim/tables.py
  16. 86 0
      netbox/dcim/tests/test_api.py
  17. 5 0
      netbox/dcim/urls.py
  18. 3 2
      netbox/dcim/views.py
  19. 32 22
      netbox/extras/api/customfields.py
  20. 54 3
      netbox/extras/api/serializers.py
  21. 3 0
      netbox/extras/api/urls.py
  22. 7 2
      netbox/extras/api/views.py
  23. 10 2
      netbox/extras/forms.py
  24. 20 0
      netbox/extras/migrations/0005_useraction_add_bulk_create.py
  25. 34 0
      netbox/extras/migrations/0006_add_imageattachments.py
  26. 62 2
      netbox/extras/models.py
  27. 12 0
      netbox/extras/urls.py
  28. 30 0
      netbox/extras/views.py
  29. 0 81
      netbox/ipam/admin.py
  30. 2 0
      netbox/ipam/api/views.py
  31. 3 3
      netbox/ipam/forms.py
  32. 86 36
      netbox/ipam/tables.py
  33. 19 3
      netbox/ipam/views.py
  34. 2 0
      netbox/media/image-attachments/.gitignore
  35. 40 0
      netbox/netbox/forms.py
  36. 14 3
      netbox/netbox/settings.py
  37. 8 2
      netbox/netbox/urls.py
  38. 152 5
      netbox/netbox/views.py
  39. 0 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css.map
  40. 0 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css.map
  41. 0 6
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css
  42. 0 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css.map
  43. 0 7
      netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.min.js
  44. 2 2
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css
  45. 1 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css.map
  46. 2 2
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css
  47. 1 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.min.css.map
  48. 2 5
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css
  49. 1 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.css.map
  50. 6 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css
  51. 1 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css.map
  52. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.eot
  53. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.svg
  54. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.ttf
  55. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff
  56. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff2
  57. 64 50
      netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.js
  58. 7 0
      netbox/project-static/bootstrap-3.3.7-dist/js/bootstrap.min.js
  59. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/js/npm.js
  60. 3 0
      netbox/project-static/css/base.css
  61. BIN
      netbox/project-static/font-awesome-4.6.3/fonts/FontAwesome.otf
  62. BIN
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.eot
  63. 0 685
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.svg
  64. BIN
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.woff
  65. BIN
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.woff2
  66. 0 0
      netbox/project-static/font-awesome-4.7.0/HELP-US-OUT.txt
  67. 141 3
      netbox/project-static/font-awesome-4.6.3/css/font-awesome.css
  68. 2 2
      netbox/project-static/font-awesome-4.6.3/css/font-awesome.min.css
  69. BIN
      netbox/project-static/font-awesome-4.7.0/fonts/FontAwesome.otf
  70. BIN
      netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.eot
  71. 2671 0
      netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.svg
  72. BIN
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.ttf
  73. BIN
      netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff
  74. BIN
      netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff2
  75. 0 0
      netbox/project-static/font-awesome-4.7.0/less/animated.less
  76. 0 0
      netbox/project-static/font-awesome-4.7.0/less/bordered-pulled.less
  77. 0 0
      netbox/project-static/font-awesome-4.7.0/less/core.less
  78. 0 0
      netbox/project-static/font-awesome-4.7.0/less/fixed-width.less
  79. 1 1
      netbox/project-static/font-awesome-4.6.3/less/font-awesome.less
  80. 56 0
      netbox/project-static/font-awesome-4.6.3/less/icons.less
  81. 0 0
      netbox/project-static/font-awesome-4.7.0/less/larger.less
  82. 0 0
      netbox/project-static/font-awesome-4.7.0/less/list.less
  83. 0 0
      netbox/project-static/font-awesome-4.7.0/less/mixins.less
  84. 0 0
      netbox/project-static/font-awesome-4.7.0/less/path.less
  85. 0 0
      netbox/project-static/font-awesome-4.7.0/less/rotated-flipped.less
  86. 0 0
      netbox/project-static/font-awesome-4.7.0/less/screen-reader.less
  87. 0 0
      netbox/project-static/font-awesome-4.7.0/less/stacked.less
  88. 58 2
      netbox/project-static/font-awesome-4.6.3/less/variables.less
  89. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_animated.scss
  90. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_bordered-pulled.scss
  91. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_core.scss
  92. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_fixed-width.scss
  93. 56 0
      netbox/project-static/font-awesome-4.6.3/scss/_icons.scss
  94. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_larger.scss
  95. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_list.scss
  96. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_mixins.scss
  97. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_path.scss
  98. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_rotated-flipped.scss
  99. 0 0
      netbox/project-static/font-awesome-4.7.0/scss/_screen-reader.scss
  100. 0 0
      netbox/project-static/font-awesome-4.6.3/scss/_stacked.scss

+ 0 - 4
README.md

@@ -1,7 +1,3 @@
-**The [2017 NetBox User Survey](https://goo.gl/forms/75HnNS2iE0Y1hVFH3) is open!** Please consider taking a moment to respond. Your feedback helps shape the pace and focus of NetBox development. The survey will remain open until 2017-03-31. Results will be published on the mailing list.
-
----
-
 ![NetBox](docs/netbox_logo.png "NetBox logo")
 
 NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.

+ 1 - 1
docs/api/authentication.md

@@ -24,7 +24,7 @@ $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
 }
 ```
 
-However, if the `[LOGIN_REQUIRED](../configuration/optional-settings/#login_required)` configuration setting has been set to `True`, all requests must be authenticated.
+However, if the [`LOGIN_REQUIRED`](../configuration/optional-settings/#login_required) configuration setting has been set to `True`, all requests must be authenticated.
 
 ```
 $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/

+ 1 - 1
docs/api/overview.md

@@ -120,7 +120,7 @@ Vary: Accept
 }
 ```
 
-The default page size derives from the `[PAGINATE_COUNT](../configuration/optional-settings/#paginate_count)` configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
+The default page size derives from the [`PAGINATE_COUNT`](../configuration/optional-settings/#paginate_count) configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
 
 ```
 http://localhost:8000/api/dcim/devices/?limit=100

+ 0 - 4
docs/data-model/dcim.md

@@ -10,10 +10,6 @@ Sites can be assigned an optional facility ID to identify the actual facility ho
 
 Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned.
 
-### Regions
-
-Sites can optionally be arranged by geographic region. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy.
-
 ---
 
 # Racks

+ 7 - 0
docs/data-model/extras.md

@@ -123,3 +123,10 @@ access-switch\d+,oob-switch\d+
 ```
 
 Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.
+
+# Image Attachments
+
+Certain objects within NetBox (namely sites, racks, and devices) can have photos or other images attached to them. (Note that _only_ image files are supported.) Each attachment may optionally be assigned a name; if omitted, the attachment will be represented by its file name.
+
+!!! note
+    If you experience a server error while attempting to upload an image attachment, verify that the system user NetBox runs as has write permission to the media root directory (`netbox/media/`).

+ 0 - 29
netbox/circuits/admin.py

@@ -1,29 +0,0 @@
-from django.contrib import admin
-
-from .models import Provider, CircuitType, Circuit
-
-
-@admin.register(Provider)
-class ProviderAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'asn']
-
-
-@admin.register(CircuitType)
-class CircuitTypeAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug']
-
-
-@admin.register(Circuit)
-class CircuitAdmin(admin.ModelAdmin):
-    list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human']
-    list_filter = ['provider', 'type', 'tenant']
-
-    def get_queryset(self, request):
-        qs = super(CircuitAdmin, self).get_queryset(request)
-        return qs.select_related('provider', 'type', 'tenant')

+ 2 - 2
netbox/circuits/forms.py

@@ -183,7 +183,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
         )
     )
@@ -192,7 +192,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         required=False,
         label='Interface',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
+            api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
             disabled_indicator='is_connected'
         )
     )

+ 35 - 17
netbox/circuits/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
 from .models import Circuit, CircuitType, Provider
 
@@ -19,9 +19,7 @@ CIRCUITTYPE_ACTIONS = """
 
 class ProviderTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name')
-    asn = tables.Column(verbose_name='ASN')
-    account = tables.Column(verbose_name='Account')
+    name = tables.LinkColumn()
     circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
 
     class Meta(BaseTable.Meta):
@@ -29,17 +27,25 @@ class ProviderTable(BaseTable):
         fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
 
+class ProviderSearchTable(SearchTable):
+    name = tables.LinkColumn()
+
+    class Meta(SearchTable.Meta):
+        model = Provider
+        fields = ('name', 'asn', 'account')
+
+
 #
 # Circuit types
 #
 
 class CircuitTypeTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
+    name = tables.LinkColumn()
     circuit_count = tables.Column(verbose_name='Circuits')
-    slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}},
-                                    verbose_name='')
+    actions = tables.TemplateColumn(
+        template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = CircuitType
@@ -52,16 +58,28 @@ class CircuitTypeTable(BaseTable):
 
 class CircuitTable(BaseTable):
     pk = ToggleColumn()
-    cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
-    type = tables.Column(verbose_name='Type')
-    provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
-                               args=[Accessor('termination_a.site.slug')])
-    z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
-                               args=[Accessor('termination_z.site.slug')])
-    description = tables.Column(verbose_name='Description')
+    cid = tables.LinkColumn(verbose_name='ID')
+    provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    a_side = tables.LinkColumn(
+        'dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
+        args=[Accessor('termination_a.site.slug')]
+    )
+    z_side = tables.LinkColumn(
+        'dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
+        args=[Accessor('termination_z.site.slug')]
+    )
 
     class Meta(BaseTable.Meta):
         model = Circuit
         fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
+
+
+class CircuitSearchTable(SearchTable):
+    cid = tables.LinkColumn(verbose_name='ID')
+    provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(SearchTable.Meta):
+        model = Circuit
+        fields = ('cid', 'type', 'provider', 'tenant', 'description')

+ 0 - 212
netbox/dcim/admin.py

@@ -1,212 +0,0 @@
-from django.contrib import admin
-from django.db.models import Count
-
-from mptt.admin import MPTTModelAdmin
-
-from .models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform,
-    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region,
-    Site,
-)
-
-
-@admin.register(Region)
-class RegionAdmin(MPTTModelAdmin):
-    list_display = ['name', 'parent', 'slug']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(Site)
-class SiteAdmin(admin.ModelAdmin):
-    list_display = ['name', 'slug', 'facility', 'asn']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(RackGroup)
-class RackGroupAdmin(admin.ModelAdmin):
-    list_display = ['name', 'slug', 'site']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(RackRole)
-class RackRoleAdmin(admin.ModelAdmin):
-    list_display = ['name', 'slug', 'color']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(Rack)
-class RackAdmin(admin.ModelAdmin):
-    list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height']
-
-
-@admin.register(RackReservation)
-class RackRackReservationAdmin(admin.ModelAdmin):
-    list_display = ['rack', 'units', 'description', 'user', 'created']
-
-
-#
-# Device types
-#
-
-@admin.register(Manufacturer)
-class ManufacturerAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug']
-
-
-class ConsolePortTemplateAdmin(admin.TabularInline):
-    model = ConsolePortTemplate
-
-
-class ConsoleServerPortTemplateAdmin(admin.TabularInline):
-    model = ConsoleServerPortTemplate
-
-
-class PowerPortTemplateAdmin(admin.TabularInline):
-    model = PowerPortTemplate
-
-
-class PowerOutletTemplateAdmin(admin.TabularInline):
-    model = PowerOutletTemplate
-
-
-class InterfaceTemplateAdmin(admin.TabularInline):
-    model = InterfaceTemplate
-
-
-class DeviceBayTemplateAdmin(admin.TabularInline):
-    model = DeviceBayTemplate
-
-
-@admin.register(DeviceType)
-class DeviceTypeAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['model'],
-    }
-    inlines = [
-        ConsolePortTemplateAdmin,
-        ConsoleServerPortTemplateAdmin,
-        PowerPortTemplateAdmin,
-        PowerOutletTemplateAdmin,
-        InterfaceTemplateAdmin,
-        DeviceBayTemplateAdmin,
-    ]
-    list_display = ['model', 'manufacturer', 'slug', 'part_number', 'u_height', 'console_ports', 'console_server_ports',
-                    'power_ports', 'power_outlets', 'interfaces', 'device_bays']
-    list_filter = ['manufacturer']
-
-    def get_queryset(self, request):
-        return DeviceType.objects.annotate(
-            console_port_count=Count('console_port_templates', distinct=True),
-            cs_port_count=Count('cs_port_templates', distinct=True),
-            power_port_count=Count('power_port_templates', distinct=True),
-            power_outlet_count=Count('power_outlet_templates', distinct=True),
-            interface_count=Count('interface_templates', distinct=True),
-            devicebay_count=Count('device_bay_templates', distinct=True),
-        )
-
-    def console_ports(self, instance):
-        return instance.console_port_count
-
-    def console_server_ports(self, instance):
-        return instance.cs_port_count
-
-    def power_ports(self, instance):
-        return instance.power_port_count
-
-    def power_outlets(self, instance):
-        return instance.power_outlet_count
-
-    def interfaces(self, instance):
-        return instance.interface_count
-
-    def device_bays(self, instance):
-        return instance.devicebay_count
-
-
-#
-# Devices
-#
-
-@admin.register(DeviceRole)
-class DeviceRoleAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'color']
-
-
-@admin.register(Platform)
-class PlatformAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'rpc_client']
-
-
-class ConsolePortAdmin(admin.TabularInline):
-    model = ConsolePort
-    readonly_fields = ['cs_port']
-
-
-class ConsoleServerPortAdmin(admin.TabularInline):
-    model = ConsoleServerPort
-
-
-class PowerPortAdmin(admin.TabularInline):
-    model = PowerPort
-    readonly_fields = ['power_outlet']
-
-
-class PowerOutletAdmin(admin.TabularInline):
-    model = PowerOutlet
-
-
-class InterfaceAdmin(admin.TabularInline):
-    model = Interface
-
-
-class DeviceBayAdmin(admin.TabularInline):
-    model = DeviceBay
-    fk_name = 'device'
-    readonly_fields = ['installed_device']
-
-
-class InventoryItemAdmin(admin.TabularInline):
-    model = InventoryItem
-    readonly_fields = ['parent', 'discovered']
-
-
-@admin.register(Device)
-class DeviceAdmin(admin.ModelAdmin):
-    inlines = [
-        ConsolePortAdmin,
-        ConsoleServerPortAdmin,
-        PowerPortAdmin,
-        PowerOutletAdmin,
-        InterfaceAdmin,
-        DeviceBayAdmin,
-        InventoryItemAdmin,
-    ]
-    list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
-                    'serial']
-    list_filter = ['device_role']
-
-    def get_queryset(self, request):
-        qs = super(DeviceAdmin, self).get_queryset(request)
-        return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack')
-
-    def device_type_full_name(self, obj):
-        return obj.device_type.full_name
-    device_type_full_name.short_description = 'Device type'

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

@@ -50,7 +50,9 @@ router.register(r'interfaces', views.InterfaceViewSet)
 router.register(r'device-bays', views.DeviceBayViewSet)
 router.register(r'inventory-items', views.InventoryItemViewSet)
 
-# Interface connections
+# Connections
+router.register(r'console-connections', views.ConsoleConnectionViewSet, base_name='consoleconnections')
+router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
 router.register(r'interface-connections', views.InterfaceConnectionViewSet)
 
 # Miscellaneous

+ 19 - 5
netbox/dcim/api/views.py

@@ -1,7 +1,8 @@
-from rest_framework.decorators import detail_route, list_route
+from rest_framework.decorators import detail_route
+from rest_framework.mixins import ListModelMixin
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet, ViewSet
+from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet, ViewSet
 
 from django.conf import settings
 from django.shortcuts import get_object_or_404
@@ -38,8 +39,8 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
 class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     queryset = Site.objects.select_related('region', 'tenant')
     serializer_class = serializers.SiteSerializer
-    filter_class = filters.SiteFilter
     write_serializer_class = serializers.WritableSiteSerializer
+    filter_class = filters.SiteFilter
 
     @detail_route()
     def graphs(self, request, pk=None):
@@ -59,8 +60,8 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = RackGroup.objects.select_related('site')
     serializer_class = serializers.RackGroupSerializer
-    filter_class = filters.RackGroupFilter
     write_serializer_class = serializers.WritableRackGroupSerializer
+    filter_class = filters.RackGroupFilter
 
 
 #
@@ -135,6 +136,7 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     queryset = DeviceType.objects.select_related('manufacturer')
     serializer_class = serializers.DeviceTypeSerializer
     write_serializer_class = serializers.WritableDeviceTypeSerializer
+    filter_class = filters.DeviceTypeFilter
 
 
 #
@@ -302,9 +304,21 @@ class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
 
 
 #
-# Interface connections
+# Connections
 #
 
+class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
+    queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False)
+    serializer_class = serializers.ConsolePortSerializer
+    filter_class = filters.ConsoleConnectionFilter
+
+
+class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
+    queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False)
+    serializer_class = serializers.PowerPortSerializer
+    filter_class = filters.PowerConnectionFilter
+
+
 class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
     serializer_class = serializers.InterfaceConnectionSerializer

+ 58 - 13
netbox/dcim/filters.py

@@ -8,9 +8,9 @@ from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate,
-    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
+    DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceTemplate, Manufacturer, InventoryItem,
+    Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
+    RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
 )
 
 
@@ -240,7 +240,7 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
 
     class Meta:
         model = InterfaceTemplate
-        fields = ['name']
+        fields = ['name', 'form_factor']
 
 
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
@@ -441,10 +441,14 @@ class InterfaceFilter(DeviceComponentFilterSet):
         method='filter_type',
         label='Interface type',
     )
+    mac_address = django_filters.CharFilter(
+        method='_mac_address',
+        label='MAC address',
+    )
 
     class Meta:
         model = Interface
-        fields = ['name']
+        fields = ['name', 'form_factor']
 
     def filter_type(self, queryset, name, value):
         value = value.strip().lower()
@@ -456,6 +460,15 @@ class InterfaceFilter(DeviceComponentFilterSet):
             return queryset.filter(form_factor=IFACE_FF_LAG)
         return queryset
 
+    def _mac_address(self, queryset, name, value):
+        value = value.strip()
+        if not value:
+            return queryset
+        try:
+            return queryset.filter(mac_address=value)
+        except AddrFormatError:
+            return queryset.none()
+
 
 class DeviceBayFilter(DeviceComponentFilterSet):
 
@@ -476,42 +489,66 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
         method='filter_site',
         label='Site (slug)',
     )
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
     class Meta:
-        model = ConsoleServerPort
-        fields = []
+        model = ConsolePort
+        fields = ['name', 'connection_status']
 
     def filter_site(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(cs_port__device__site__slug=value)
 
+    def filter_device(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(device__name__icontains=value) |
+            Q(cs_port__device__name__icontains=value)
+        )
+
 
 class PowerConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
         method='filter_site',
         label='Site (slug)',
     )
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
     class Meta:
-        model = PowerOutlet
-        fields = []
+        model = PowerPort
+        fields = ['name', 'connection_status']
 
     def filter_site(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(power_outlet__device__site__slug=value)
 
+    def filter_device(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(device__name__icontains=value) |
+            Q(power_outlet__device__name__icontains=value)
+        )
+
 
 class InterfaceConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
         method='filter_site',
         label='Site (slug)',
     )
-
-    class Meta:
-        model = InterfaceConnection
-        fields = []
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
     def filter_site(self, queryset, name, value):
         if not value.strip():
@@ -520,3 +557,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             Q(interface_a__device__site__slug=value) |
             Q(interface_b__device__site__slug=value)
         )
+
+    def filter_device(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(interface_a__device__name__icontains=value) |
+            Q(interface_b__device__name__icontains=value)
+        )

+ 30 - 12
netbox/dcim/forms.py

@@ -23,7 +23,7 @@ from .models import (
     Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
     Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
     RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
-    VIRTUAL_IFACE_TYPES
+    SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES
 )
 
 
@@ -375,6 +375,21 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
         to_field_name='slug'
     )
+    is_console_server = forms.BooleanField(
+        required=False, label='Is a console server', widget=forms.CheckboxInput(attrs={'value': 'True'}))
+    is_pdu = forms.BooleanField(
+        required=False, label='Is a PDU', widget=forms.CheckboxInput(attrs={'value': 'True'})
+    )
+    is_network_device = forms.BooleanField(
+        required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'})
+    )
+    subdevice_role = forms.NullBooleanField(
+        required=False, label='Subdevice role', widget=forms.Select(choices=(
+            ('', '---------'),
+            (SUBDEVICE_ROLE_PARENT, 'Parent'),
+            (SUBDEVICE_ROLE_CHILD, 'Child'),
+        ))
+    )
 
 
 #
@@ -914,7 +929,7 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
         label='Console Server',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='console_server',
         )
     )
@@ -922,7 +937,7 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=ConsoleServerPort.objects.all(),
         label='Port',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
+            api_url='/api/dcim/console-server-ports/?device_id={{device}}',
             disabled_indicator='connected_console',
         )
     )
@@ -1015,7 +1030,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
         )
     )
@@ -1023,7 +1038,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
         queryset=ConsolePort.objects.all(),
         label='Port',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/console-ports/',
+            api_url='/api/dcim/console-ports/?device_id={{device}}',
             disabled_indicator='cs_port'
         )
     )
@@ -1182,7 +1197,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         label='PDU',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='pdu'
         )
     )
@@ -1190,7 +1205,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=PowerOutlet.objects.all(),
         label='Outlet',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
+            api_url='/api/dcim/power-outlets/?device_id={{device}}',
             disabled_indicator='connected_port'
         )
     )
@@ -1281,7 +1296,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
         )
     )
@@ -1289,7 +1304,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
         queryset=PowerPort.objects.all(),
         label='Port',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/power-ports/',
+            api_url='/api/dcim/power-ports/?device_id={{device}}',
             disabled_indicator='power_outlet'
         )
     )
@@ -1444,7 +1459,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device_b'
         )
     )
@@ -1452,7 +1467,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=Interface.objects.all(),
         label='Interface',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
+            api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
             disabled_indicator='is_connected'
         )
     )
@@ -1466,7 +1481,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
 
         # Initialize interface A choices
-        device_a_interfaces = Interface.objects.filter(device=device_a).exclude(
+        device_a_interfaces = Interface.objects.order_naturally().filter(device=device_a).exclude(
             form_factor__in=VIRTUAL_IFACE_TYPES
         ).select_related(
             'circuit_termination', 'connected_as_a', 'connected_as_b'
@@ -1643,14 +1658,17 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
 
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
+    device = forms.CharField(required=False, label='Device name')
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
+    device = forms.CharField(required=False, label='Device name')
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
+    device = forms.CharField(required=False, label='Device name')
 
 
 #

+ 8 - 5
netbox/dcim/models.py

@@ -15,7 +15,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
 from django.utils.encoding import python_2_unicode_compatible
 
 from circuits.models import Circuit
-from extras.models import CustomFieldModel, CustomField, CustomFieldValue
+from extras.models import CustomFieldModel, CustomField, CustomFieldValue, ImageAttachment
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
 from utilities.fields import ColorField, NullableCharField
@@ -254,6 +254,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
     objects = SiteManager()
 
@@ -375,6 +376,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
                                      help_text='Units are numbered top-to-bottom')
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
     objects = RackManager()
 
@@ -904,11 +906,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
 
-    Each Device must be assigned to a Rack, although associating it with a particular rack face or unit is optional (for
-    example, vertically mounted PDUs do not consume rack units).
+    Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a
+    particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units).
 
-    When a new Device is created, console/power/interface components are created along with it as dictated by the
-    component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
+    When a new Device is created, console/power/interface/device bay components are created along with it as dictated
+    by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
     creation of a Device.
     """
     device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
@@ -932,6 +934,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
                                        blank=True, null=True, verbose_name='Primary IPv6')
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
     objects = DeviceManager()
 

+ 91 - 25
netbox/dcim/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
@@ -100,6 +100,10 @@ DEVICE_PRIMARY_IP = """
 {{ record.primary_ip4.address.ip|default:"" }}
 """
 
+SUBDEVICE_ROLE_TEMPLATE = """
+{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
+"""
+
 UTILIZATION_GRAPH = """
 {% load helpers %}
 {% utilization_graph value %}
@@ -132,11 +136,9 @@ class RegionTable(BaseTable):
 
 class SiteTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
-    facility = tables.Column(verbose_name='Facility')
-    region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    asn = tables.Column(verbose_name='ASN')
+    name = tables.LinkColumn()
+    region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
     prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
@@ -151,6 +153,16 @@ class SiteTable(BaseTable):
         )
 
 
+class SiteSearchTable(SearchTable):
+    name = tables.LinkColumn()
+    region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(SearchTable.Meta):
+        model = Site
+        fields = ('name', 'facility', 'region', 'tenant', 'asn')
+
+
 #
 # Rack groups
 #
@@ -193,20 +205,33 @@ class RackRoleTable(BaseTable):
 
 class RackTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    name = tables.LinkColumn()
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    facility_id = tables.Column(verbose_name='Facility ID')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    role = tables.TemplateColumn(RACK_ROLE)
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
-    devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
+    devices = tables.Column(accessor=Accessor('device_count'))
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
     class Meta(BaseTable.Meta):
         model = Rack
-        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices',
-                  'get_utilization')
+        fields = (
+            'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
+        )
+
+
+class RackSearchTable(SearchTable):
+    name = tables.LinkColumn()
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    role = tables.TemplateColumn(RACK_ROLE)
+    u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
+
+    class Meta(SearchTable.Meta):
+        model = Rack
+        fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height')
 
 
 class RackImportTable(BaseTable):
@@ -245,15 +270,36 @@ class ManufacturerTable(BaseTable):
 
 class DeviceTypeTable(BaseTable):
     pk = ToggleColumn()
-    manufacturer = tables.Column(verbose_name='Manufacturer')
     model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
-    part_number = tables.Column(verbose_name='Part Number')
     is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
+    is_console_server = tables.BooleanColumn(verbose_name='CS')
+    is_pdu = tables.BooleanColumn(verbose_name='PDU')
+    is_network_device = tables.BooleanColumn(verbose_name='Net')
+    subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
     instance_count = tables.Column(verbose_name='Instances')
 
     class Meta(BaseTable.Meta):
         model = DeviceType
-        fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count')
+        fields = (
+            'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
+            'is_network_device', 'subdevice_role', 'instance_count'
+        )
+
+
+class DeviceTypeSearchTable(SearchTable):
+    model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
+    is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
+    is_console_server = tables.BooleanColumn(verbose_name='CS')
+    is_pdu = tables.BooleanColumn(verbose_name='PDU')
+    is_network_device = tables.BooleanColumn(verbose_name='Net')
+    subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
+
+    class Meta(SearchTable.Meta):
+        model = DeviceType
+        fields = (
+            'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
+            'is_network_device', 'subdevice_role',
+        )
 
 
 #
@@ -362,22 +408,42 @@ class PlatformTable(BaseTable):
 
 class DeviceTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.TemplateColumn(template_code=DEVICE_LINK)
     status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
-    name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
     device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
-    device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
-                                    text=lambda record: record.device_type.full_name)
-    primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
-                                       template_code=DEVICE_PRIMARY_IP)
+    device_type = tables.LinkColumn(
+        'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
+        text=lambda record: record.device_type.full_name
+    )
+    primary_ip = tables.TemplateColumn(
+        orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP
+    )
 
     class Meta(BaseTable.Meta):
         model = Device
         fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
 
 
+class DeviceSearchTable(SearchTable):
+    name = tables.TemplateColumn(template_code=DEVICE_LINK)
+    status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
+    device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
+    device_type = tables.LinkColumn(
+        'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
+        text=lambda record: record.device_type.full_name
+    )
+
+    class Meta(SearchTable.Meta):
+        model = Device
+        fields = ('name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type')
+
+
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')

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

@@ -1933,6 +1933,92 @@ class InventoryItemTest(HttpStatusMixin, APITestCase):
         self.assertEqual(InventoryItem.objects.count(), 2)
 
 
+class ConsoleConnectionTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+        )
+        devicerole = DeviceRole.objects.create(
+            name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+        )
+        device1 = Device.objects.create(
+            device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
+        )
+        device2 = Device.objects.create(
+            device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site
+        )
+        cs_port1 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 1')
+        cs_port2 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 2')
+        cs_port3 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 3')
+        ConsolePort.objects.create(
+            device=device2, cs_port=cs_port1, name='Test Console Port 1', connection_status=True
+        )
+        ConsolePort.objects.create(
+            device=device2, cs_port=cs_port2, name='Test Console Port 2', connection_status=True
+        )
+        ConsolePort.objects.create(
+            device=device2, cs_port=cs_port3, name='Test Console Port 3', connection_status=True
+        )
+
+    def test_list_consoleconnections(self):
+
+        url = reverse('dcim-api:consoleconnections-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+
+class PowerConnectionTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+        )
+        devicerole = DeviceRole.objects.create(
+            name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+        )
+        device1 = Device.objects.create(
+            device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
+        )
+        device2 = Device.objects.create(
+            device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site
+        )
+        power_outlet1 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 1')
+        power_outlet2 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 2')
+        power_outlet3 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 3')
+        PowerPort.objects.create(
+            device=device2, power_outlet=power_outlet1, name='Test Power Port 1', connection_status=True
+        )
+        PowerPort.objects.create(
+            device=device2, power_outlet=power_outlet2, name='Test Power Port 2', connection_status=True
+        )
+        PowerPort.objects.create(
+            device=device2, power_outlet=power_outlet3, name='Test Power Port 3', connection_status=True
+        )
+
+    def test_list_powerconnections(self):
+
+        url = reverse('dcim-api:powerconnections-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+
 class InterfaceConnectionTest(HttpStatusMixin, APITestCase):
 
     def setUp(self):

+ 5 - 0
netbox/dcim/urls.py

@@ -3,6 +3,8 @@ from django.conf.urls import url
 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,6 +24,7 @@ urlpatterns = [
     url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
+    url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
     # Rack groups
     url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
@@ -49,6 +52,7 @@ urlpatterns = [
     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'),
+    url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
     # Manufacturers
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -117,6 +121,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<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}),
 
     # Console ports
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),

+ 3 - 2
netbox/dcim/views.py

@@ -1450,9 +1450,10 @@ def interfaceconnection_add(request, pk):
             ))
             if '_addanother' in request.POST:
                 base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
+                device_b = interfaceconnection.interface_b.device
                 params = urlencode({
-                    'rack_b': interfaceconnection.interface_b.device.rack.pk,
-                    'device_b': interfaceconnection.interface_b.device.pk,
+                    'rack_b': device_b.rack.pk if device_b.rack else '',
+                    'device_b': device_b.pk,
                 })
                 return HttpResponseRedirect('{}?{}'.format(base_url, params))
             else:

+ 32 - 22
netbox/extras/api/customfields.py

@@ -2,47 +2,57 @@ from django.contrib.contenttypes.models import ContentType
 
 from rest_framework import serializers
 
-from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice
+from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
 
 
 #
 # Custom fields
 #
 
-class CustomFieldSerializer(serializers.BaseSerializer):
-    """
-    Extends ModelSerializer to render any CustomFields and their values associated with an object.
-    """
-
-    def to_representation(self, manager):
+class CustomFieldsSerializer(serializers.BaseSerializer):
 
-        # Initialize custom fields dictionary
-        data = {f.name: None for f in self.parent._custom_fields}
-
-        # Assign CustomFieldValues from database
-        for cfv in manager.all():
-            if cfv.field.type == CF_TYPE_SELECT:
-                data[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data
-            else:
-                data[cfv.field.name] = cfv.value
-
-        return data
+    def to_representation(self, obj):
+        return obj
 
 
 class CustomFieldModelSerializer(serializers.ModelSerializer):
-    custom_fields = CustomFieldSerializer(source='custom_field_values')
+    """
+    Extends ModelSerializer to render any CustomFields and their values associated with an object.
+    """
+    custom_fields = CustomFieldsSerializer()
 
     def __init__(self, *args, **kwargs):
 
+        def _populate_custom_fields(instance, fields):
+            custom_fields = {f.name: None for f in fields}
+            for cfv in instance.custom_field_values.all():
+                if cfv.field.type == CF_TYPE_SELECT:
+                    custom_fields[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data
+                else:
+                    custom_fields[cfv.field.name] = cfv.value
+            instance.custom_fields = custom_fields
+
         super(CustomFieldModelSerializer, self).__init__(*args, **kwargs)
 
-        # Cache the list of custom fields for this model
+        # Retrieve the set of CustomFields which apply to this type of object
         content_type = ContentType.objects.get_for_model(self.Meta.model)
-        self._custom_fields = CustomField.objects.filter(obj_type=content_type)
+        fields = CustomField.objects.filter(obj_type=content_type)
+
+        # Populate CustomFieldValues for each instance from database
+        try:
+            for obj in self.instance:
+                _populate_custom_fields(obj, fields)
+        except TypeError:
+            _populate_custom_fields(self.instance, fields)
 
 
 class CustomFieldChoiceSerializer(serializers.ModelSerializer):
+    """
+    Imitate utilities.api.ChoiceFieldSerializer
+    """
+    value = serializers.IntegerField(source='pk')
+    label = serializers.CharField(source='value')
 
     class Meta:
         model = CustomFieldChoice
-        fields = ['id', 'value']
+        fields = ['value', 'label']

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

@@ -1,9 +1,14 @@
 from rest_framework import serializers
 
-from dcim.api.serializers import NestedSiteSerializer
-from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, ExportTemplate, TopologyMap, UserAction
+from django.core.exceptions import ObjectDoesNotExist
+
+from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
+from dcim.models import Device, Rack, Site
+from extras.models import (
+    ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
+)
 from users.api.serializers import NestedUserSerializer
-from utilities.api import ChoiceFieldSerializer
+from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer
 
 
 #
@@ -72,6 +77,52 @@ class WritableTopologyMapSerializer(serializers.ModelSerializer):
 
 
 #
+# Image attachments
+#
+
+class ImageAttachmentSerializer(serializers.ModelSerializer):
+    parent = serializers.SerializerMethodField()
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created']
+
+    def get_parent(self, obj):
+
+        # Static mapping of models to their nested serializers
+        if isinstance(obj.parent, Device):
+            serializer = NestedDeviceSerializer
+        elif isinstance(obj.parent, Rack):
+            serializer = NestedRackSerializer
+        elif isinstance(obj.parent, Site):
+            serializer = NestedSiteSerializer
+        else:
+            raise Exception("Unexpected type of parent object for ImageAttachment")
+
+        return serializer(obj.parent, context={'request': self.context['request']}).data
+
+
+class WritableImageAttachmentSerializer(serializers.ModelSerializer):
+    content_type = ContentTypeFieldSerializer()
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'content_type', 'object_id', 'name', 'image']
+
+    def validate(self, data):
+
+        # Validate that the parent object exists
+        try:
+            data['content_type'].get_object_for_this_type(id=data['object_id'])
+        except ObjectDoesNotExist:
+            raise serializers.ValidationError(
+                "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
+            )
+
+        return data
+
+
+#
 # User actions
 #
 

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

@@ -23,6 +23,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet)
 # Topology maps
 router.register(r'topology-maps', views.TopologyMapViewSet)
 
+# Image attachments
+router.register(r'image-attachments', views.ImageAttachmentViewSet)
+
 # Recent activity
 router.register(r'recent-activity', views.RecentActivityViewSet)
 

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

@@ -6,7 +6,7 @@ from django.http import HttpResponse
 from django.shortcuts import get_object_or_404
 
 from extras import filters
-from extras.models import ExportTemplate, Graph, TopologyMap, UserAction
+from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction
 from utilities.api import WritableSerializerMixin
 from . import serializers
 
@@ -51,7 +51,6 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
 class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
-    # write_serializer_class = serializers.WritableExportTemplateSerializer
     filter_class = filters.ExportTemplateFilter
 
 
@@ -81,6 +80,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
         return response
 
 
+class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = ImageAttachment.objects.all()
+    serializer_class = serializers.ImageAttachmentSerializer
+    write_serializer_class = serializers.WritableImageAttachmentSerializer
+
+
 class RecentActivityViewSet(ReadOnlyModelViewSet):
     """
     List all UserActions to provide a log of recent activity.

+ 10 - 2
netbox/extras/forms.py

@@ -3,9 +3,10 @@ from collections import OrderedDict
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 
-from utilities.forms import BulkEditForm, LaxURLField
+from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
 from .models import (
-    CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
+    CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue,
+    ImageAttachment,
 )
 
 
@@ -158,3 +159,10 @@ class CustomFieldFilterForm(forms.Form):
         for name, field in custom_fields:
             field.required = False
             self.fields[name] = field
+
+
+class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['name', 'image']

+ 20 - 0
netbox/extras/migrations/0005_useraction_add_bulk_create.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-04-04 19:45
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0004_topologymap_change_comma_to_semicolon'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='useraction',
+            name='action',
+            field=models.PositiveSmallIntegerField(choices=[(1, b'created'), (7, b'bulk created'), (2, b'imported'), (3, b'modified'), (4, b'bulk edited'), (5, b'deleted'), (6, b'bulk deleted')]),
+        ),
+    ]

+ 34 - 0
netbox/extras/migrations/0006_add_imageattachments.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-04-04 19:58
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0005_useraction_add_bulk_create'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ImageAttachment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('object_id', models.PositiveIntegerField()),
+                ('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')),
+                ('image_height', models.PositiveSmallIntegerField()),
+                ('image_width', models.PositiveSmallIntegerField()),
+                ('name', models.CharField(blank=True, max_length=50)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+    ]

+ 62 - 2
netbox/extras/models.py

@@ -58,13 +58,15 @@ ACTION_EDIT = 3
 ACTION_BULK_EDIT = 4
 ACTION_DELETE = 5
 ACTION_BULK_DELETE = 6
+ACTION_BULK_CREATE = 7
 ACTION_CHOICES = (
     (ACTION_CREATE, 'created'),
+    (ACTION_BULK_CREATE, 'bulk created'),
     (ACTION_IMPORT, 'imported'),
     (ACTION_EDIT, 'modified'),
     (ACTION_BULK_EDIT, 'bulk edited'),
     (ACTION_DELETE, 'deleted'),
-    (ACTION_BULK_DELETE, 'bulk deleted')
+    (ACTION_BULK_DELETE, 'bulk deleted'),
 )
 
 
@@ -360,6 +362,61 @@ class TopologyMap(models.Model):
 
 
 #
+# Image attachments
+#
+
+def image_upload(instance, filename):
+
+    path = 'image-attachments/'
+
+    # Rename the file to the provided name, if any. Attempt to preserve the file extension.
+    extension = filename.rsplit('.')[-1]
+    if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
+        filename = '.'.join([instance.name, extension])
+    elif instance.name:
+        filename = instance.name
+
+    return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
+
+
+@python_2_unicode_compatible
+class ImageAttachment(models.Model):
+    """
+    An uploaded image which is associated with an object.
+    """
+    content_type = models.ForeignKey(ContentType)
+    object_id = models.PositiveIntegerField()
+    parent = GenericForeignKey('content_type', 'object_id')
+    image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width')
+    image_height = models.PositiveSmallIntegerField()
+    image_width = models.PositiveSmallIntegerField()
+    name = models.CharField(max_length=50, blank=True)
+    created = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        if self.name:
+            return self.name
+        filename = self.image.name.rsplit('/', 1)[-1]
+        return filename.split('_', 2)[2]
+
+    def delete(self, *args, **kwargs):
+
+        _name = self.image.name
+
+        super(ImageAttachment, self).delete(*args, **kwargs)
+
+        # Delete file from disk
+        self.image.delete(save=False)
+
+        # Deleting the file erases its name. We restore the image's filename here in case we still need to reference it
+        # before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
+        self.image.name = _name
+
+
+#
 # User actions
 #
 
@@ -396,6 +453,9 @@ class UserActionManager(models.Manager):
     def log_import(self, user, content_type, message=''):
         self.log_bulk_action(user, content_type, ACTION_IMPORT, message)
 
+    def log_bulk_create(self, user, content_type, message=''):
+        self.log_bulk_action(user, content_type, ACTION_BULK_CREATE, message)
+
     def log_bulk_edit(self, user, content_type, message=''):
         self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message)
 
@@ -426,7 +486,7 @@ class UserAction(models.Model):
         return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
 
     def icon(self):
-        if self.action in [ACTION_CREATE, ACTION_IMPORT]:
+        if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:
             return mark_safe('<i class="glyphicon glyphicon-plus text-success"></i>')
         elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]:
             return mark_safe('<i class="glyphicon glyphicon-pencil text-warning"></i>')

+ 12 - 0
netbox/extras/urls.py

@@ -0,0 +1,12 @@
+from django.conf.urls import url
+
+from extras import views
+
+
+urlpatterns = [
+
+    # Image attachments
+    url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
+    url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
+
+]

+ 30 - 0
netbox/extras/views.py

@@ -0,0 +1,30 @@
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.shortcuts import get_object_or_404
+
+from utilities.views import ObjectDeleteView, ObjectEditView
+from .forms import ImageAttachmentForm
+from .models import ImageAttachment
+
+
+class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'extras.change_imageattachment'
+    model = ImageAttachment
+    form_class = ImageAttachmentForm
+
+    def alter_obj(self, imageattachment, request, args, kwargs):
+        if not imageattachment.pk:
+            # Assign the parent object based on URL kwargs
+            model = kwargs.get('model')
+            imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
+        return imageattachment
+
+    def get_return_url(self, imageattachment):
+        return imageattachment.parent.get_absolute_url()
+
+
+class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_imageattachment'
+    model = ImageAttachment
+
+    def get_return_url(self, imageattachment):
+        return imageattachment.obj.get_absolute_url()

+ 0 - 81
netbox/ipam/admin.py

@@ -1,81 +0,0 @@
-from django.contrib import admin
-
-from .models import (
-    Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF,
-)
-
-
-@admin.register(VRF)
-class VRFAdmin(admin.ModelAdmin):
-    list_display = ['name', 'rd', 'tenant', 'enforce_unique']
-    list_filter = ['tenant']
-
-    def get_queryset(self, request):
-        qs = super(VRFAdmin, self).get_queryset(request)
-        return qs.select_related('tenant')
-
-
-@admin.register(Role)
-class RoleAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'weight']
-
-
-@admin.register(RIR)
-class RIRAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'is_private']
-
-
-@admin.register(Aggregate)
-class AggregateAdmin(admin.ModelAdmin):
-    list_display = ['prefix', 'rir', 'date_added']
-    list_filter = ['family', 'rir']
-    search_fields = ['prefix']
-
-
-@admin.register(Prefix)
-class PrefixAdmin(admin.ModelAdmin):
-    list_display = ['prefix', 'vrf', 'tenant', 'site', 'status', 'role', 'vlan']
-    list_filter = ['family', 'site', 'status', 'role']
-    search_fields = ['prefix']
-
-    def get_queryset(self, request):
-        qs = super(PrefixAdmin, self).get_queryset(request)
-        return qs.select_related('vrf', 'site', 'role', 'vlan')
-
-
-@admin.register(IPAddress)
-class IPAddressAdmin(admin.ModelAdmin):
-    list_display = ['address', 'vrf', 'tenant', 'nat_inside']
-    list_filter = ['family']
-    fields = ['address', 'vrf', 'device', 'interface', 'nat_inside']
-    readonly_fields = ['interface', 'device', 'nat_inside']
-    search_fields = ['address']
-
-    def get_queryset(self, request):
-        qs = super(IPAddressAdmin, self).get_queryset(request)
-        return qs.select_related('vrf', 'nat_inside')
-
-
-@admin.register(VLANGroup)
-class VLANGroupAdmin(admin.ModelAdmin):
-    list_display = ['name', 'site', 'slug']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(VLAN)
-class VLANAdmin(admin.ModelAdmin):
-    list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role']
-    list_filter = ['site', 'tenant', 'status', 'role']
-    search_fields = ['vid', 'name']
-
-    def get_queryset(self, request):
-        qs = super(VLANAdmin, self).get_queryset(request)
-        return qs.select_related('site', 'tenant', 'role')

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

@@ -34,6 +34,7 @@ class RoleViewSet(ModelViewSet):
 class RIRViewSet(ModelViewSet):
     queryset = RIR.objects.all()
     serializer_class = serializers.RIRSerializer
+    filter_class = filters.RIRFilter
 
 
 #
@@ -99,3 +100,4 @@ class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = Service.objects.select_related('device')
     serializer_class = serializers.ServiceSerializer
     write_serializer_class = serializers.WritableServiceSerializer
+    filter_class = filters.ServiceFilter

+ 3 - 3
netbox/ipam/forms.py

@@ -310,7 +310,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                                                          display_field='display_name',
                                                          attrs={'filter-for': 'nat_inside'}))
     livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
-        query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
+        query_key='q', query_url='ipam-api:ipaddress-list', field_to_update='nat_inside', obj_label='address')
     )
 
     class Meta:
@@ -404,7 +404,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
         )
     )
@@ -412,7 +412,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
         queryset=Interface.objects.all(),
         label='Interface',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/interfaces/'
+            api_url='/api/dcim/interfaces/?device_id={{device}}'
         )
     )
     set_as_primary = forms.BooleanField(

+ 86 - 36
netbox/ipam/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
@@ -133,16 +133,25 @@ TENANT_LINK = """
 
 class VRFTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
+    name = tables.LinkColumn()
     rd = tables.Column(verbose_name='RD')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    description = tables.Column(verbose_name='Description')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
 
     class Meta(BaseTable.Meta):
         model = VRF
         fields = ('pk', 'name', 'rd', 'tenant', 'description')
 
 
+class VRFSearchTable(SearchTable):
+    name = tables.LinkColumn()
+    rd = tables.Column(verbose_name='RD')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(SearchTable.Meta):
+        model = VRF
+        fields = ('name', 'rd', 'tenant', 'description')
+
+
 #
 # RIRs
 #
@@ -177,18 +186,25 @@ class RIRTable(BaseTable):
 
 class AggregateTable(BaseTable):
     pk = ToggleColumn()
-    prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
-    rir = tables.Column(verbose_name='RIR')
+    prefix = tables.LinkColumn(verbose_name='Aggregate')
     child_count = tables.Column(verbose_name='Prefixes')
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
-    description = tables.Column(verbose_name='Description')
 
     class Meta(BaseTable.Meta):
         model = Aggregate
         fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
 
 
+class AggregateSearchTable(SearchTable):
+    prefix = tables.LinkColumn(verbose_name='Aggregate')
+    date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
+
+    class Meta(SearchTable.Meta):
+        model = Aggregate
+        fields = ('prefix', 'rir', 'date_added', 'description')
+
+
 #
 # Roles
 #
@@ -212,14 +228,13 @@ class RoleTable(BaseTable):
 
 class PrefixTable(BaseTable):
     pk = ToggleColumn()
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}})
+    prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
+    status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
-    role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
-    description = tables.Column(verbose_name='Description')
+    role = tables.TemplateColumn(PREFIX_ROLE_LINK)
 
     class Meta(BaseTable.Meta):
         model = Prefix
@@ -230,12 +245,11 @@ class PrefixTable(BaseTable):
 
 
 class PrefixBriefTable(BaseTable):
-    prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix')
-    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
-    role = tables.Column(verbose_name='Role')
+    prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF)
+    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    status = tables.TemplateColumn(STATUS_LABEL)
+    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')])
 
     class Meta(BaseTable.Meta):
         model = Prefix
@@ -243,6 +257,20 @@ class PrefixBriefTable(BaseTable):
         orderable = False
 
 
+class PrefixSearchTable(SearchTable):
+    prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
+    status = tables.TemplateColumn(STATUS_LABEL)
+    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
+    role = tables.TemplateColumn(PREFIX_ROLE_LINK)
+
+    class Meta(SearchTable.Meta):
+        model = Prefix
+        fields = ('prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
+
+
 #
 # IPAddresses
 #
@@ -250,13 +278,11 @@ class PrefixBriefTable(BaseTable):
 class IPAddressTable(BaseTable):
     pk = ToggleColumn()
     address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
+    status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
-    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
-                               verbose_name='Device')
-    interface = tables.Column(orderable=False, verbose_name='Interface')
-    description = tables.Column(verbose_name='Description')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
+    interface = tables.Column(orderable=False)
 
     class Meta(BaseTable.Meta):
         model = IPAddress
@@ -268,17 +294,30 @@ class IPAddressTable(BaseTable):
 
 class IPAddressBriefTable(BaseTable):
     address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
-    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
-                               verbose_name='Device')
-    interface = tables.Column(orderable=False, verbose_name='Interface')
-    nat_inside = tables.LinkColumn('ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False,
-                                   verbose_name='NAT (Inside)')
+    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
+    interface = tables.Column(orderable=False)
+    nat_inside = tables.LinkColumn(
+        'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
+    )
 
     class Meta(BaseTable.Meta):
         model = IPAddress
         fields = ('address', 'device', 'interface', 'nat_inside')
 
 
+class IPAddressSearchTable(SearchTable):
+    address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
+    status = tables.TemplateColumn(STATUS_LABEL)
+    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
+    interface = tables.Column(orderable=False)
+
+    class Meta(SearchTable.Meta):
+        model = IPAddress
+        fields = ('address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
+
+
 #
 # VLAN groups
 #
@@ -304,15 +343,26 @@ class VLANGroupTable(BaseTable):
 class VLANTable(BaseTable):
     pk = ToggleColumn()
     vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    name = tables.Column(verbose_name='Name')
     prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
-    description = tables.Column(verbose_name='Description')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    status = tables.TemplateColumn(STATUS_LABEL)
+    role = tables.TemplateColumn(VLAN_ROLE_LINK)
 
     class Meta(BaseTable.Meta):
         model = VLAN
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
+
+
+class VLANSearchTable(SearchTable):
+    vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    status = tables.TemplateColumn(STATUS_LABEL)
+    role = tables.TemplateColumn(VLAN_ROLE_LINK)
+
+    class Meta(SearchTable.Meta):
+        model = VLAN
+        fields = ('vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')

+ 19 - 3
netbox/ipam/views.py

@@ -1,6 +1,7 @@
 from django_tables2 import RequestConfig
 import netaddr
 
+from django.conf import settings
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib import messages
@@ -295,7 +296,12 @@ def aggregate(request, 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
-    RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
+
+    paginate = {
+        'klass': EnhancedPaginator,
+        'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+    }
+    RequestConfig(request, paginate).configure(prefix_table)
 
     # Compile permissions list for rendering the object table
     permissions = {
@@ -427,7 +433,12 @@ def prefix(request, pk):
     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
-    RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
+
+    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 = {
@@ -500,7 +511,12 @@ def prefix_ipaddresses(request, pk):
     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
-    RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
+
+    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 = {

+ 2 - 0
netbox/media/image-attachments/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 40 - 0
netbox/netbox/forms.py

@@ -0,0 +1,40 @@
+from django import forms
+
+from utilities.forms import BootstrapMixin
+
+
+OBJ_TYPE_CHOICES = (
+    ('', 'All Objects'),
+    ('Circuits', (
+        ('provider', 'Providers'),
+        ('circuit', 'Circuits'),
+    )),
+    ('DCIM', (
+        ('site', 'Sites'),
+        ('rack', 'Racks'),
+        ('devicetype', 'Device types'),
+        ('device', 'Devices'),
+    )),
+    ('IPAM', (
+        ('vrf', 'VRFs'),
+        ('aggregate', 'Aggregates'),
+        ('prefix', 'Prefixes'),
+        ('ipaddress', 'IP addresses'),
+        ('vlan', 'VLANs'),
+    )),
+    ('Secrets', (
+        ('secret', 'Secrets'),
+    )),
+    ('Tenancy', (
+        ('tenant', 'Tenants'),
+    )),
+)
+
+
+class SearchForm(BootstrapMixin, forms.Form):
+    q = forms.CharField(
+        label='Query', widget=forms.TextInput(attrs={'style': 'width: 350px'})
+    )
+    obj_type = forms.ChoiceField(
+        choices=OBJ_TYPE_CHOICES, required=False, label='Type'
+    )

+ 14 - 3
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
 
 
-VERSION = '2.0-beta1'
+VERSION = '2.0-beta2'
 
 # Import local configuration
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
@@ -153,6 +153,7 @@ TEMPLATES = [
             'context_processors': [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',
+                'django.template.context_processors.media',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 'utilities.context_processors.settings',
@@ -167,19 +168,21 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 USE_X_FORWARDED_HOST = True
 
 # Internationalization
-# https://docs.djangoproject.com/en/1.8/topics/i18n/
 LANGUAGE_CODE = 'en-us'
 USE_I18N = True
 USE_TZ = True
 
 # Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/1.8/howto/static-files/
 STATIC_ROOT = BASE_DIR + '/static/'
 STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATICFILES_DIRS = (
     os.path.join(BASE_DIR, "project-static"),
 )
 
+# Media
+MEDIA_URL = '/media/'
+MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+
 # 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
 
@@ -215,6 +218,14 @@ REST_FRAMEWORK = {
 }
 
 # Django debug toolbar
+# Disable the templates panel by default due to a performance issue in Django 1.11; see
+# https://github.com/jazzband/django-debug-toolbar/issues/910
+DEBUG_TOOLBAR_CONFIG = {
+    'DISABLE_PANELS': [
+        'debug_toolbar.panels.redirects.RedirectsPanel',
+        'debug_toolbar.panels.templates.TemplatesPanel',
+    ],
+}
 INTERNAL_IPS = (
     '127.0.0.1',
     '::1',

+ 8 - 2
netbox/netbox/urls.py

@@ -1,8 +1,9 @@
 from django.conf import settings
 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, trigger_500
+from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
 from users.views import login, logout
 
 
@@ -10,8 +11,9 @@ handler500 = handle_500
 
 _patterns = [
 
-    # Default page
+    # Base views
     url(r'^$', home, name='home'),
+    url(r'^search/$', SearchView.as_view(), name='search'),
 
     # Login/logout
     url(r'^login/$', login, name='login'),
@@ -20,6 +22,7 @@ _patterns = [
     # Apps
     url(r'^circuits/', include('circuits.urls', namespace='circuits')),
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
+    url(r'^extras/', include('extras.urls', namespace='extras')),
     url(r'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
     url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
@@ -35,6 +38,9 @@ _patterns = [
     url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
     url(r'^api/docs/', include('rest_framework_swagger.urls')),
 
+    # Serving static media in Django to pipe it through LoginRequiredMiddleware
+    url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
+
     # Error testing
     url(r'^500/$', trigger_500),
 

+ 152 - 5
netbox/netbox/views.py

@@ -1,18 +1,117 @@
 import sys
 
-from rest_framework.permissions import IsAuthenticated
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 
 from django.shortcuts import render
-
-from circuits.models import Provider, Circuit
-from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
+from django.views.generic import View
+
+from circuits.filters import CircuitFilter, ProviderFilter
+from circuits.models import Circuit, Provider
+from circuits.tables import CircuitSearchTable, ProviderSearchTable
+from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
+from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
+from dcim.tables import DeviceSearchTable, DeviceTypeSearchTable, RackSearchTable, SiteSearchTable
 from extras.models import UserAction
-from ipam.models import Aggregate, Prefix, IPAddress, VLAN, VRF
+from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
+from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
+from ipam.tables import AggregateSearchTable, IPAddressSearchTable, PrefixSearchTable, VLANSearchTable, VRFSearchTable
+from secrets.filters import SecretFilter
 from secrets.models import Secret
+from secrets.tables import SecretSearchTable
+from tenancy.filters import TenantFilter
 from tenancy.models import Tenant
+from tenancy.tables import TenantSearchTable
+from .forms import SearchForm
+
+
+SEARCH_MAX_RESULTS = 15
+SEARCH_TYPES = {
+    # Circuits
+    'provider': {
+        'queryset': Provider.objects.all(),
+        'filter': ProviderFilter,
+        'table': ProviderSearchTable,
+        'url': 'circuits:provider_list',
+    },
+    'circuit': {
+        'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'),
+        'filter': CircuitFilter,
+        'table': CircuitSearchTable,
+        'url': 'circuits:circuit_list',
+    },
+    # DCIM
+    'site': {
+        'queryset': Site.objects.select_related('region', 'tenant'),
+        'filter': SiteFilter,
+        'table': SiteSearchTable,
+        'url': 'dcim:site_list',
+    },
+    'rack': {
+        'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
+        'filter': RackFilter,
+        'table': RackSearchTable,
+        'url': 'dcim:rack_list',
+    },
+    'devicetype': {
+        'queryset': DeviceType.objects.select_related('manufacturer'),
+        'filter': DeviceTypeFilter,
+        'table': DeviceTypeSearchTable,
+        'url': 'dcim:devicetype_list',
+    },
+    'device': {
+        'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'),
+        'filter': DeviceFilter,
+        'table': DeviceSearchTable,
+        'url': 'dcim:device_list',
+    },
+    # IPAM
+    'vrf': {
+        'queryset': VRF.objects.select_related('tenant'),
+        'filter': VRFFilter,
+        'table': VRFSearchTable,
+        'url': 'ipam:vrf_list',
+    },
+    'aggregate': {
+        'queryset': Aggregate.objects.select_related('rir'),
+        'filter': AggregateFilter,
+        'table': AggregateSearchTable,
+        'url': 'ipam:aggregate_list',
+    },
+    'prefix': {
+        'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
+        'filter': PrefixFilter,
+        'table': PrefixSearchTable,
+        'url': 'ipam:prefix_list',
+    },
+    'ipaddress': {
+        'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
+        'filter': IPAddressFilter,
+        'table': IPAddressSearchTable,
+        'url': 'ipam:ipaddress_list',
+    },
+    'vlan': {
+        'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
+        'filter': VLANFilter,
+        'table': VLANSearchTable,
+        'url': 'ipam:vlan_list',
+    },
+    # Secrets
+    'secret': {
+        'queryset': Secret.objects.select_related('role', 'device'),
+        'filter': SecretFilter,
+        'table': SecretSearchTable,
+        'url': 'secrets:secret_list',
+    },
+    # Tenancy
+    'tenant': {
+        'queryset': Tenant.objects.select_related('group'),
+        'filter': TenantFilter,
+        'table': TenantSearchTable,
+        'url': 'tenancy:tenant_list',
+    },
+}
 
 
 def home(request):
@@ -47,11 +146,59 @@ def home(request):
     }
 
     return render(request, 'home.html', {
+        'search_form': SearchForm(),
         'stats': stats,
         'recent_activity': UserAction.objects.select_related('user')[:50]
     })
 
 
+class SearchView(View):
+
+    def get(self, request):
+
+        # No query
+        if 'q' not in request.GET:
+            return render(request, 'search.html', {
+                'form': SearchForm(),
+            })
+
+        form = SearchForm(request.GET)
+        results = []
+
+        if form.is_valid():
+
+            # Searching for a single type of object
+            if form.cleaned_data['obj_type']:
+                obj_types = [form.cleaned_data['obj_type']]
+            # Searching all object types
+            else:
+                obj_types = SEARCH_TYPES.keys()
+
+            for obj_type in obj_types:
+
+                queryset = SEARCH_TYPES[obj_type]['queryset']
+                filter_cls = SEARCH_TYPES[obj_type]['filter']
+                table = SEARCH_TYPES[obj_type]['table']
+                url = SEARCH_TYPES[obj_type]['url']
+
+                # Construct the results table for this object type
+                filtered_queryset = filter_cls({'q': form.cleaned_data['q']}, queryset=queryset).qs
+                table = table(filtered_queryset)
+                table.paginate(per_page=SEARCH_MAX_RESULTS)
+
+                if table.page:
+                    results.append({
+                        'name': queryset.model._meta.verbose_name_plural,
+                        'table': table,
+                        'url': '{}?q={}'.format(reverse(url), form.cleaned_data['q'])
+                    })
+
+        return render(request, 'search.html', {
+            'form': form,
+            'results': results,
+        })
+
+
 class APIRootView(APIView):
     _ignore_model_permissions = True
     exclude_from_schema = True

File diff suppressed because it is too large
+ 0 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css.map


File diff suppressed because it is too large
+ 0 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css.map


File diff suppressed because it is too large
+ 0 - 6
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css


File diff suppressed because it is too large
+ 0 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css.map


File diff suppressed because it is too large
+ 0 - 7
netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.min.js


+ 2 - 2
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css

@@ -1,6 +1,6 @@
 /*!
- * Bootstrap v3.3.6 (http://getbootstrap.com)
- * Copyright 2011-2015 Twitter, Inc.
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  */
 .btn-default,

File diff suppressed because it is too large
+ 1 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css.map


File diff suppressed because it is too large
+ 2 - 2
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css


File diff suppressed because it is too large
+ 1 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.min.css.map


+ 2 - 5
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css

@@ -1,6 +1,6 @@
 /*!
- * Bootstrap v3.3.6 (http://getbootstrap.com)
- * Copyright 2011-2015 Twitter, Inc.
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  */
 /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
@@ -1106,7 +1106,6 @@ a:focus {
   text-decoration: underline;
 }
 a:focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
 }
@@ -2537,7 +2536,6 @@ select[size] {
 input[type="file"]:focus,
 input[type="radio"]:focus,
 input[type="checkbox"]:focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
 }
@@ -3029,7 +3027,6 @@ select[multiple].input-lg {
 .btn.focus,
 .btn:active.focus,
 .btn.active.focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
 }

File diff suppressed because it is too large
+ 1 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.css.map


File diff suppressed because it is too large
+ 6 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css


File diff suppressed because it is too large
+ 1 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css.map


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.eot → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.eot


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.svg → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.svg


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.ttf → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.ttf


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.woff → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.woff2 → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff2


+ 64 - 50
netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.js

@@ -1,6 +1,6 @@
 /*!
- * Bootstrap v3.3.6 (http://getbootstrap.com)
- * Copyright 2011-2015 Twitter, Inc.
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under the MIT license
  */
 
@@ -11,16 +11,16 @@ if (typeof jQuery === 'undefined') {
 +function ($) {
   'use strict';
   var version = $.fn.jquery.split(' ')[0].split('.')
-  if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) {
-    throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3')
+  if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)) {
+    throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4')
   }
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: transition.js v3.3.6
+ * Bootstrap: transition.js v3.3.7
  * http://getbootstrap.com/javascript/#transitions
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -77,10 +77,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: alert.js v3.3.6
+ * Bootstrap: alert.js v3.3.7
  * http://getbootstrap.com/javascript/#alerts
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -96,7 +96,7 @@ if (typeof jQuery === 'undefined') {
     $(el).on('click', dismiss, this.close)
   }
 
-  Alert.VERSION = '3.3.6'
+  Alert.VERSION = '3.3.7'
 
   Alert.TRANSITION_DURATION = 150
 
@@ -109,7 +109,7 @@ if (typeof jQuery === 'undefined') {
       selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
     }
 
-    var $parent = $(selector)
+    var $parent = $(selector === '#' ? [] : selector)
 
     if (e) e.preventDefault()
 
@@ -172,10 +172,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: button.js v3.3.6
+ * Bootstrap: button.js v3.3.7
  * http://getbootstrap.com/javascript/#buttons
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -192,7 +192,7 @@ if (typeof jQuery === 'undefined') {
     this.isLoading = false
   }
 
-  Button.VERSION  = '3.3.6'
+  Button.VERSION  = '3.3.7'
 
   Button.DEFAULTS = {
     loadingText: 'loading...'
@@ -214,10 +214,10 @@ if (typeof jQuery === 'undefined') {
 
       if (state == 'loadingText') {
         this.isLoading = true
-        $el.addClass(d).attr(d, d)
+        $el.addClass(d).attr(d, d).prop(d, true)
       } else if (this.isLoading) {
         this.isLoading = false
-        $el.removeClass(d).removeAttr(d)
+        $el.removeClass(d).removeAttr(d).prop(d, false)
       }
     }, this), 0)
   }
@@ -281,10 +281,15 @@ if (typeof jQuery === 'undefined') {
 
   $(document)
     .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) {
-      var $btn = $(e.target)
-      if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn')
+      var $btn = $(e.target).closest('.btn')
       Plugin.call($btn, 'toggle')
-      if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault()
+      if (!($(e.target).is('input[type="radio"], input[type="checkbox"]'))) {
+        // Prevent double click on radios, and the double selections (so cancellation) on checkboxes
+        e.preventDefault()
+        // The target component still receive the focus
+        if ($btn.is('input,button')) $btn.trigger('focus')
+        else $btn.find('input:visible,button:visible').first().trigger('focus')
+      }
     })
     .on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) {
       $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type))
@@ -293,10 +298,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: carousel.js v3.3.6
+ * Bootstrap: carousel.js v3.3.7
  * http://getbootstrap.com/javascript/#carousel
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -324,7 +329,7 @@ if (typeof jQuery === 'undefined') {
       .on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
   }
 
-  Carousel.VERSION  = '3.3.6'
+  Carousel.VERSION  = '3.3.7'
 
   Carousel.TRANSITION_DURATION = 600
 
@@ -531,13 +536,14 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: collapse.js v3.3.6
+ * Bootstrap: collapse.js v3.3.7
  * http://getbootstrap.com/javascript/#collapse
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
+/* jshint latedef: false */
 
 +function ($) {
   'use strict';
@@ -561,7 +567,7 @@ if (typeof jQuery === 'undefined') {
     if (this.options.toggle) this.toggle()
   }
 
-  Collapse.VERSION  = '3.3.6'
+  Collapse.VERSION  = '3.3.7'
 
   Collapse.TRANSITION_DURATION = 350
 
@@ -743,10 +749,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: dropdown.js v3.3.6
+ * Bootstrap: dropdown.js v3.3.7
  * http://getbootstrap.com/javascript/#dropdowns
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -763,7 +769,7 @@ if (typeof jQuery === 'undefined') {
     $(element).on('click.bs.dropdown', this.toggle)
   }
 
-  Dropdown.VERSION = '3.3.6'
+  Dropdown.VERSION = '3.3.7'
 
   function getParent($this) {
     var selector = $this.attr('data-target')
@@ -909,10 +915,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: modal.js v3.3.6
+ * Bootstrap: modal.js v3.3.7
  * http://getbootstrap.com/javascript/#modals
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -943,7 +949,7 @@ if (typeof jQuery === 'undefined') {
     }
   }
 
-  Modal.VERSION  = '3.3.6'
+  Modal.VERSION  = '3.3.7'
 
   Modal.TRANSITION_DURATION = 300
   Modal.BACKDROP_TRANSITION_DURATION = 150
@@ -1050,7 +1056,9 @@ if (typeof jQuery === 'undefined') {
     $(document)
       .off('focusin.bs.modal') // guard against infinite focus loop
       .on('focusin.bs.modal', $.proxy(function (e) {
-        if (this.$element[0] !== e.target && !this.$element.has(e.target).length) {
+        if (document !== e.target &&
+            this.$element[0] !== e.target &&
+            !this.$element.has(e.target).length) {
           this.$element.trigger('focus')
         }
       }, this))
@@ -1247,11 +1255,11 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: tooltip.js v3.3.6
+ * Bootstrap: tooltip.js v3.3.7
  * http://getbootstrap.com/javascript/#tooltip
  * Inspired by the original jQuery.tipsy by Jason Frame
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -1274,7 +1282,7 @@ if (typeof jQuery === 'undefined') {
     this.init('tooltip', element, options)
   }
 
-  Tooltip.VERSION  = '3.3.6'
+  Tooltip.VERSION  = '3.3.7'
 
   Tooltip.TRANSITION_DURATION = 150
 
@@ -1565,9 +1573,11 @@ if (typeof jQuery === 'undefined') {
 
     function complete() {
       if (that.hoverState != 'in') $tip.detach()
-      that.$element
-        .removeAttr('aria-describedby')
-        .trigger('hidden.bs.' + that.type)
+      if (that.$element) { // TODO: Check whether guarding this code with this `if` is really necessary.
+        that.$element
+          .removeAttr('aria-describedby')
+          .trigger('hidden.bs.' + that.type)
+      }
       callback && callback()
     }
 
@@ -1610,7 +1620,10 @@ if (typeof jQuery === 'undefined') {
       // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093
       elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top })
     }
-    var elOffset  = isBody ? { top: 0, left: 0 } : $element.offset()
+    var isSvg = window.SVGElement && el instanceof window.SVGElement
+    // Avoid using $.offset() on SVGs since it gives incorrect results in jQuery 3.
+    // See https://github.com/twbs/bootstrap/issues/20280
+    var elOffset  = isBody ? { top: 0, left: 0 } : (isSvg ? null : $element.offset())
     var scroll    = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() }
     var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null
 
@@ -1726,6 +1739,7 @@ if (typeof jQuery === 'undefined') {
       that.$tip = null
       that.$arrow = null
       that.$viewport = null
+      that.$element = null
     })
   }
 
@@ -1762,10 +1776,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: popover.js v3.3.6
+ * Bootstrap: popover.js v3.3.7
  * http://getbootstrap.com/javascript/#popovers
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -1782,7 +1796,7 @@ if (typeof jQuery === 'undefined') {
 
   if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js')
 
-  Popover.VERSION  = '3.3.6'
+  Popover.VERSION  = '3.3.7'
 
   Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
     placement: 'right',
@@ -1871,10 +1885,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: scrollspy.js v3.3.6
+ * Bootstrap: scrollspy.js v3.3.7
  * http://getbootstrap.com/javascript/#scrollspy
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -1900,7 +1914,7 @@ if (typeof jQuery === 'undefined') {
     this.process()
   }
 
-  ScrollSpy.VERSION  = '3.3.6'
+  ScrollSpy.VERSION  = '3.3.7'
 
   ScrollSpy.DEFAULTS = {
     offset: 10
@@ -2044,10 +2058,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: tab.js v3.3.6
+ * Bootstrap: tab.js v3.3.7
  * http://getbootstrap.com/javascript/#tabs
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -2064,7 +2078,7 @@ if (typeof jQuery === 'undefined') {
     // jscs:enable requireDollarBeforejQueryAssignment
   }
 
-  Tab.VERSION = '3.3.6'
+  Tab.VERSION = '3.3.7'
 
   Tab.TRANSITION_DURATION = 150
 
@@ -2200,10 +2214,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: affix.js v3.3.6
+ * Bootstrap: affix.js v3.3.7
  * http://getbootstrap.com/javascript/#affix
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -2229,7 +2243,7 @@ if (typeof jQuery === 'undefined') {
     this.checkPosition()
   }
 
-  Affix.VERSION  = '3.3.6'
+  Affix.VERSION  = '3.3.7'
 
   Affix.RESET    = 'affix affix-top affix-bottom'
 

File diff suppressed because it is too large
+ 7 - 0
netbox/project-static/bootstrap-3.3.7-dist/js/bootstrap.min.js


netbox/project-static/bootstrap-3.3.6-dist/js/npm.js → netbox/project-static/bootstrap-3.3.7-dist/js/npm.js


+ 3 - 0
netbox/project-static/css/base.css

@@ -92,6 +92,9 @@ tfoot td {
 table.attr-table td:nth-child(1) {
     width: 25%;
 }
+.table-headings th {
+    background-color: #f5f5f5;
+}
 
 /* Paginator */
 div.paginator {

BIN
netbox/project-static/font-awesome-4.6.3/fonts/FontAwesome.otf


BIN
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.eot


File diff suppressed because it is too large
+ 0 - 685
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.svg


BIN
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.woff


BIN
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.woff2


netbox/project-static/font-awesome-4.6.3/HELP-US-OUT.txt → netbox/project-static/font-awesome-4.7.0/HELP-US-OUT.txt


+ 141 - 3
netbox/project-static/font-awesome-4.6.3/css/font-awesome.css

@@ -1,13 +1,13 @@
 /*!
- *  Font Awesome 4.6.3 by @davegandy - http://fontawesome.io - @fontawesome
+ *  Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
  *  License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
  */
 /* FONT PATH
  * -------------------------- */
 @font-face {
   font-family: 'FontAwesome';
-  src: url('../fonts/fontawesome-webfont.eot?v=4.6.3');
-  src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.6.3') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.6.3') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.6.3') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.6.3') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.6.3#fontawesomeregular') format('svg');
+  src: url('../fonts/fontawesome-webfont.eot?v=4.7.0');
+  src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');
   font-weight: normal;
   font-style: normal;
 }
@@ -1832,6 +1832,7 @@
   content: "\f23e";
 }
 .fa-battery-4:before,
+.fa-battery:before,
 .fa-battery-full:before {
   content: "\f240";
 }
@@ -2178,6 +2179,143 @@
 .fa-font-awesome:before {
   content: "\f2b4";
 }
+.fa-handshake-o:before {
+  content: "\f2b5";
+}
+.fa-envelope-open:before {
+  content: "\f2b6";
+}
+.fa-envelope-open-o:before {
+  content: "\f2b7";
+}
+.fa-linode:before {
+  content: "\f2b8";
+}
+.fa-address-book:before {
+  content: "\f2b9";
+}
+.fa-address-book-o:before {
+  content: "\f2ba";
+}
+.fa-vcard:before,
+.fa-address-card:before {
+  content: "\f2bb";
+}
+.fa-vcard-o:before,
+.fa-address-card-o:before {
+  content: "\f2bc";
+}
+.fa-user-circle:before {
+  content: "\f2bd";
+}
+.fa-user-circle-o:before {
+  content: "\f2be";
+}
+.fa-user-o:before {
+  content: "\f2c0";
+}
+.fa-id-badge:before {
+  content: "\f2c1";
+}
+.fa-drivers-license:before,
+.fa-id-card:before {
+  content: "\f2c2";
+}
+.fa-drivers-license-o:before,
+.fa-id-card-o:before {
+  content: "\f2c3";
+}
+.fa-quora:before {
+  content: "\f2c4";
+}
+.fa-free-code-camp:before {
+  content: "\f2c5";
+}
+.fa-telegram:before {
+  content: "\f2c6";
+}
+.fa-thermometer-4:before,
+.fa-thermometer:before,
+.fa-thermometer-full:before {
+  content: "\f2c7";
+}
+.fa-thermometer-3:before,
+.fa-thermometer-three-quarters:before {
+  content: "\f2c8";
+}
+.fa-thermometer-2:before,
+.fa-thermometer-half:before {
+  content: "\f2c9";
+}
+.fa-thermometer-1:before,
+.fa-thermometer-quarter:before {
+  content: "\f2ca";
+}
+.fa-thermometer-0:before,
+.fa-thermometer-empty:before {
+  content: "\f2cb";
+}
+.fa-shower:before {
+  content: "\f2cc";
+}
+.fa-bathtub:before,
+.fa-s15:before,
+.fa-bath:before {
+  content: "\f2cd";
+}
+.fa-podcast:before {
+  content: "\f2ce";
+}
+.fa-window-maximize:before {
+  content: "\f2d0";
+}
+.fa-window-minimize:before {
+  content: "\f2d1";
+}
+.fa-window-restore:before {
+  content: "\f2d2";
+}
+.fa-times-rectangle:before,
+.fa-window-close:before {
+  content: "\f2d3";
+}
+.fa-times-rectangle-o:before,
+.fa-window-close-o:before {
+  content: "\f2d4";
+}
+.fa-bandcamp:before {
+  content: "\f2d5";
+}
+.fa-grav:before {
+  content: "\f2d6";
+}
+.fa-etsy:before {
+  content: "\f2d7";
+}
+.fa-imdb:before {
+  content: "\f2d8";
+}
+.fa-ravelry:before {
+  content: "\f2d9";
+}
+.fa-eercast:before {
+  content: "\f2da";
+}
+.fa-microchip:before {
+  content: "\f2db";
+}
+.fa-snowflake-o:before {
+  content: "\f2dc";
+}
+.fa-superpowers:before {
+  content: "\f2dd";
+}
+.fa-wpexplorer:before {
+  content: "\f2de";
+}
+.fa-meetup:before {
+  content: "\f2e0";
+}
 .sr-only {
   position: absolute;
   width: 1px;

File diff suppressed because it is too large
+ 2 - 2
netbox/project-static/font-awesome-4.6.3/css/font-awesome.min.css


BIN
netbox/project-static/font-awesome-4.7.0/fonts/FontAwesome.otf


BIN
netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.eot


File diff suppressed because it is too large
+ 2671 - 0
netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.svg


BIN
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.ttf


BIN
netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff


BIN
netbox/project-static/font-awesome-4.7.0/fonts/fontawesome-webfont.woff2


netbox/project-static/font-awesome-4.6.3/less/animated.less → netbox/project-static/font-awesome-4.7.0/less/animated.less


netbox/project-static/font-awesome-4.6.3/less/bordered-pulled.less → netbox/project-static/font-awesome-4.7.0/less/bordered-pulled.less


netbox/project-static/font-awesome-4.6.3/less/core.less → netbox/project-static/font-awesome-4.7.0/less/core.less


netbox/project-static/font-awesome-4.6.3/less/fixed-width.less → netbox/project-static/font-awesome-4.7.0/less/fixed-width.less


+ 1 - 1
netbox/project-static/font-awesome-4.6.3/less/font-awesome.less

@@ -1,5 +1,5 @@
 /*!
- *  Font Awesome 4.6.3 by @davegandy - http://fontawesome.io - @fontawesome
+ *  Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
  *  License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
  */
 

+ 56 - 0
netbox/project-static/font-awesome-4.6.3/less/icons.less

@@ -605,6 +605,7 @@
 .@{fa-css-prefix}-opencart:before { content: @fa-var-opencart; }
 .@{fa-css-prefix}-expeditedssl:before { content: @fa-var-expeditedssl; }
 .@{fa-css-prefix}-battery-4:before,
+.@{fa-css-prefix}-battery:before,
 .@{fa-css-prefix}-battery-full:before { content: @fa-var-battery-full; }
 .@{fa-css-prefix}-battery-3:before,
 .@{fa-css-prefix}-battery-three-quarters:before { content: @fa-var-battery-three-quarters; }
@@ -731,3 +732,58 @@
 .@{fa-css-prefix}-google-plus-official:before { content: @fa-var-google-plus-official; }
 .@{fa-css-prefix}-fa:before,
 .@{fa-css-prefix}-font-awesome:before { content: @fa-var-font-awesome; }
+.@{fa-css-prefix}-handshake-o:before { content: @fa-var-handshake-o; }
+.@{fa-css-prefix}-envelope-open:before { content: @fa-var-envelope-open; }
+.@{fa-css-prefix}-envelope-open-o:before { content: @fa-var-envelope-open-o; }
+.@{fa-css-prefix}-linode:before { content: @fa-var-linode; }
+.@{fa-css-prefix}-address-book:before { content: @fa-var-address-book; }
+.@{fa-css-prefix}-address-book-o:before { content: @fa-var-address-book-o; }
+.@{fa-css-prefix}-vcard:before,
+.@{fa-css-prefix}-address-card:before { content: @fa-var-address-card; }
+.@{fa-css-prefix}-vcard-o:before,
+.@{fa-css-prefix}-address-card-o:before { content: @fa-var-address-card-o; }
+.@{fa-css-prefix}-user-circle:before { content: @fa-var-user-circle; }
+.@{fa-css-prefix}-user-circle-o:before { content: @fa-var-user-circle-o; }
+.@{fa-css-prefix}-user-o:before { content: @fa-var-user-o; }
+.@{fa-css-prefix}-id-badge:before { content: @fa-var-id-badge; }
+.@{fa-css-prefix}-drivers-license:before,
+.@{fa-css-prefix}-id-card:before { content: @fa-var-id-card; }
+.@{fa-css-prefix}-drivers-license-o:before,
+.@{fa-css-prefix}-id-card-o:before { content: @fa-var-id-card-o; }
+.@{fa-css-prefix}-quora:before { content: @fa-var-quora; }
+.@{fa-css-prefix}-free-code-camp:before { content: @fa-var-free-code-camp; }
+.@{fa-css-prefix}-telegram:before { content: @fa-var-telegram; }
+.@{fa-css-prefix}-thermometer-4:before,
+.@{fa-css-prefix}-thermometer:before,
+.@{fa-css-prefix}-thermometer-full:before { content: @fa-var-thermometer-full; }
+.@{fa-css-prefix}-thermometer-3:before,
+.@{fa-css-prefix}-thermometer-three-quarters:before { content: @fa-var-thermometer-three-quarters; }
+.@{fa-css-prefix}-thermometer-2:before,
+.@{fa-css-prefix}-thermometer-half:before { content: @fa-var-thermometer-half; }
+.@{fa-css-prefix}-thermometer-1:before,
+.@{fa-css-prefix}-thermometer-quarter:before { content: @fa-var-thermometer-quarter; }
+.@{fa-css-prefix}-thermometer-0:before,
+.@{fa-css-prefix}-thermometer-empty:before { content: @fa-var-thermometer-empty; }
+.@{fa-css-prefix}-shower:before { content: @fa-var-shower; }
+.@{fa-css-prefix}-bathtub:before,
+.@{fa-css-prefix}-s15:before,
+.@{fa-css-prefix}-bath:before { content: @fa-var-bath; }
+.@{fa-css-prefix}-podcast:before { content: @fa-var-podcast; }
+.@{fa-css-prefix}-window-maximize:before { content: @fa-var-window-maximize; }
+.@{fa-css-prefix}-window-minimize:before { content: @fa-var-window-minimize; }
+.@{fa-css-prefix}-window-restore:before { content: @fa-var-window-restore; }
+.@{fa-css-prefix}-times-rectangle:before,
+.@{fa-css-prefix}-window-close:before { content: @fa-var-window-close; }
+.@{fa-css-prefix}-times-rectangle-o:before,
+.@{fa-css-prefix}-window-close-o:before { content: @fa-var-window-close-o; }
+.@{fa-css-prefix}-bandcamp:before { content: @fa-var-bandcamp; }
+.@{fa-css-prefix}-grav:before { content: @fa-var-grav; }
+.@{fa-css-prefix}-etsy:before { content: @fa-var-etsy; }
+.@{fa-css-prefix}-imdb:before { content: @fa-var-imdb; }
+.@{fa-css-prefix}-ravelry:before { content: @fa-var-ravelry; }
+.@{fa-css-prefix}-eercast:before { content: @fa-var-eercast; }
+.@{fa-css-prefix}-microchip:before { content: @fa-var-microchip; }
+.@{fa-css-prefix}-snowflake-o:before { content: @fa-var-snowflake-o; }
+.@{fa-css-prefix}-superpowers:before { content: @fa-var-superpowers; }
+.@{fa-css-prefix}-wpexplorer:before { content: @fa-var-wpexplorer; }
+.@{fa-css-prefix}-meetup:before { content: @fa-var-meetup; }

netbox/project-static/font-awesome-4.6.3/less/larger.less → netbox/project-static/font-awesome-4.7.0/less/larger.less


netbox/project-static/font-awesome-4.6.3/less/list.less → netbox/project-static/font-awesome-4.7.0/less/list.less


netbox/project-static/font-awesome-4.6.3/less/mixins.less → netbox/project-static/font-awesome-4.7.0/less/mixins.less


netbox/project-static/font-awesome-4.6.3/less/path.less → netbox/project-static/font-awesome-4.7.0/less/path.less


netbox/project-static/font-awesome-4.6.3/less/rotated-flipped.less → netbox/project-static/font-awesome-4.7.0/less/rotated-flipped.less


netbox/project-static/font-awesome-4.6.3/less/screen-reader.less → netbox/project-static/font-awesome-4.7.0/less/screen-reader.less


netbox/project-static/font-awesome-4.6.3/less/stacked.less → netbox/project-static/font-awesome-4.7.0/less/stacked.less


+ 58 - 2
netbox/project-static/font-awesome-4.6.3/less/variables.less

@@ -4,14 +4,18 @@
 @fa-font-path:        "../fonts";
 @fa-font-size-base:   14px;
 @fa-line-height-base: 1;
-//@fa-font-path:        "//netdna.bootstrapcdn.com/font-awesome/4.6.3/fonts"; // for referencing Bootstrap CDN font files directly
+//@fa-font-path:        "//netdna.bootstrapcdn.com/font-awesome/4.7.0/fonts"; // for referencing Bootstrap CDN font files directly
 @fa-css-prefix:       fa;
-@fa-version:          "4.6.3";
+@fa-version:          "4.7.0";
 @fa-border-color:     #eee;
 @fa-inverse:          #fff;
 @fa-li-width:         (30em / 14);
 
 @fa-var-500px: "\f26e";
+@fa-var-address-book: "\f2b9";
+@fa-var-address-book-o: "\f2ba";
+@fa-var-address-card: "\f2bb";
+@fa-var-address-card-o: "\f2bc";
 @fa-var-adjust: "\f042";
 @fa-var-adn: "\f170";
 @fa-var-align-center: "\f037";
@@ -60,11 +64,15 @@
 @fa-var-backward: "\f04a";
 @fa-var-balance-scale: "\f24e";
 @fa-var-ban: "\f05e";
+@fa-var-bandcamp: "\f2d5";
 @fa-var-bank: "\f19c";
 @fa-var-bar-chart: "\f080";
 @fa-var-bar-chart-o: "\f080";
 @fa-var-barcode: "\f02a";
 @fa-var-bars: "\f0c9";
+@fa-var-bath: "\f2cd";
+@fa-var-bathtub: "\f2cd";
+@fa-var-battery: "\f240";
 @fa-var-battery-0: "\f244";
 @fa-var-battery-1: "\f243";
 @fa-var-battery-2: "\f242";
@@ -214,19 +222,25 @@
 @fa-var-dot-circle-o: "\f192";
 @fa-var-download: "\f019";
 @fa-var-dribbble: "\f17d";
+@fa-var-drivers-license: "\f2c2";
+@fa-var-drivers-license-o: "\f2c3";
 @fa-var-dropbox: "\f16b";
 @fa-var-drupal: "\f1a9";
 @fa-var-edge: "\f282";
 @fa-var-edit: "\f044";
+@fa-var-eercast: "\f2da";
 @fa-var-eject: "\f052";
 @fa-var-ellipsis-h: "\f141";
 @fa-var-ellipsis-v: "\f142";
 @fa-var-empire: "\f1d1";
 @fa-var-envelope: "\f0e0";
 @fa-var-envelope-o: "\f003";
+@fa-var-envelope-open: "\f2b6";
+@fa-var-envelope-open-o: "\f2b7";
 @fa-var-envelope-square: "\f199";
 @fa-var-envira: "\f299";
 @fa-var-eraser: "\f12d";
+@fa-var-etsy: "\f2d7";
 @fa-var-eur: "\f153";
 @fa-var-euro: "\f153";
 @fa-var-exchange: "\f0ec";
@@ -294,6 +308,7 @@
 @fa-var-forumbee: "\f211";
 @fa-var-forward: "\f04e";
 @fa-var-foursquare: "\f180";
+@fa-var-free-code-camp: "\f2c5";
 @fa-var-frown-o: "\f119";
 @fa-var-futbol-o: "\f1e3";
 @fa-var-gamepad: "\f11b";
@@ -326,6 +341,7 @@
 @fa-var-google-wallet: "\f1ee";
 @fa-var-graduation-cap: "\f19d";
 @fa-var-gratipay: "\f184";
+@fa-var-grav: "\f2d6";
 @fa-var-group: "\f0c0";
 @fa-var-h-square: "\f0fd";
 @fa-var-hacker-news: "\f1d4";
@@ -342,6 +358,7 @@
 @fa-var-hand-scissors-o: "\f257";
 @fa-var-hand-spock-o: "\f259";
 @fa-var-hand-stop-o: "\f256";
+@fa-var-handshake-o: "\f2b5";
 @fa-var-hard-of-hearing: "\f2a4";
 @fa-var-hashtag: "\f292";
 @fa-var-hdd-o: "\f0a0";
@@ -365,8 +382,12 @@
 @fa-var-houzz: "\f27c";
 @fa-var-html5: "\f13b";
 @fa-var-i-cursor: "\f246";
+@fa-var-id-badge: "\f2c1";
+@fa-var-id-card: "\f2c2";
+@fa-var-id-card-o: "\f2c3";
 @fa-var-ils: "\f20b";
 @fa-var-image: "\f03e";
+@fa-var-imdb: "\f2d8";
 @fa-var-inbox: "\f01c";
 @fa-var-indent: "\f03c";
 @fa-var-industry: "\f275";
@@ -404,6 +425,7 @@
 @fa-var-link: "\f0c1";
 @fa-var-linkedin: "\f0e1";
 @fa-var-linkedin-square: "\f08c";
+@fa-var-linode: "\f2b8";
 @fa-var-linux: "\f17c";
 @fa-var-list: "\f03a";
 @fa-var-list-alt: "\f022";
@@ -436,8 +458,10 @@
 @fa-var-meanpath: "\f20c";
 @fa-var-medium: "\f23a";
 @fa-var-medkit: "\f0fa";
+@fa-var-meetup: "\f2e0";
 @fa-var-meh-o: "\f11a";
 @fa-var-mercury: "\f223";
+@fa-var-microchip: "\f2db";
 @fa-var-microphone: "\f130";
 @fa-var-microphone-slash: "\f131";
 @fa-var-minus: "\f068";
@@ -502,6 +526,7 @@
 @fa-var-plus-circle: "\f055";
 @fa-var-plus-square: "\f0fe";
 @fa-var-plus-square-o: "\f196";
+@fa-var-podcast: "\f2ce";
 @fa-var-power-off: "\f011";
 @fa-var-print: "\f02f";
 @fa-var-product-hunt: "\f288";
@@ -511,10 +536,12 @@
 @fa-var-question: "\f128";
 @fa-var-question-circle: "\f059";
 @fa-var-question-circle-o: "\f29c";
+@fa-var-quora: "\f2c4";
 @fa-var-quote-left: "\f10d";
 @fa-var-quote-right: "\f10e";
 @fa-var-ra: "\f1d0";
 @fa-var-random: "\f074";
+@fa-var-ravelry: "\f2d9";
 @fa-var-rebel: "\f1d0";
 @fa-var-recycle: "\f1b8";
 @fa-var-reddit: "\f1a1";
@@ -541,6 +568,7 @@
 @fa-var-rub: "\f158";
 @fa-var-ruble: "\f158";
 @fa-var-rupee: "\f156";
+@fa-var-s15: "\f2cd";
 @fa-var-safari: "\f267";
 @fa-var-save: "\f0c7";
 @fa-var-scissors: "\f0c4";
@@ -565,6 +593,7 @@
 @fa-var-shopping-bag: "\f290";
 @fa-var-shopping-basket: "\f291";
 @fa-var-shopping-cart: "\f07a";
+@fa-var-shower: "\f2cc";
 @fa-var-sign-in: "\f090";
 @fa-var-sign-language: "\f2a7";
 @fa-var-sign-out: "\f08b";
@@ -581,6 +610,7 @@
 @fa-var-snapchat: "\f2ab";
 @fa-var-snapchat-ghost: "\f2ac";
 @fa-var-snapchat-square: "\f2ad";
+@fa-var-snowflake-o: "\f2dc";
 @fa-var-soccer-ball-o: "\f1e3";
 @fa-var-sort: "\f0dc";
 @fa-var-sort-alpha-asc: "\f15d";
@@ -626,6 +656,7 @@
 @fa-var-subway: "\f239";
 @fa-var-suitcase: "\f0f2";
 @fa-var-sun-o: "\f185";
+@fa-var-superpowers: "\f2dd";
 @fa-var-superscript: "\f12b";
 @fa-var-support: "\f1cd";
 @fa-var-table: "\f0ce";
@@ -635,6 +666,7 @@
 @fa-var-tags: "\f02c";
 @fa-var-tasks: "\f0ae";
 @fa-var-taxi: "\f1ba";
+@fa-var-telegram: "\f2c6";
 @fa-var-television: "\f26c";
 @fa-var-tencent-weibo: "\f1d5";
 @fa-var-terminal: "\f120";
@@ -644,6 +676,17 @@
 @fa-var-th-large: "\f009";
 @fa-var-th-list: "\f00b";
 @fa-var-themeisle: "\f2b2";
+@fa-var-thermometer: "\f2c7";
+@fa-var-thermometer-0: "\f2cb";
+@fa-var-thermometer-1: "\f2ca";
+@fa-var-thermometer-2: "\f2c9";
+@fa-var-thermometer-3: "\f2c8";
+@fa-var-thermometer-4: "\f2c7";
+@fa-var-thermometer-empty: "\f2cb";
+@fa-var-thermometer-full: "\f2c7";
+@fa-var-thermometer-half: "\f2c9";
+@fa-var-thermometer-quarter: "\f2ca";
+@fa-var-thermometer-three-quarters: "\f2c8";
 @fa-var-thumb-tack: "\f08d";
 @fa-var-thumbs-down: "\f165";
 @fa-var-thumbs-o-down: "\f088";
@@ -653,6 +696,8 @@
 @fa-var-times: "\f00d";
 @fa-var-times-circle: "\f057";
 @fa-var-times-circle-o: "\f05c";
+@fa-var-times-rectangle: "\f2d3";
+@fa-var-times-rectangle-o: "\f2d4";
 @fa-var-tint: "\f043";
 @fa-var-toggle-down: "\f150";
 @fa-var-toggle-left: "\f191";
@@ -693,11 +738,16 @@
 @fa-var-usb: "\f287";
 @fa-var-usd: "\f155";
 @fa-var-user: "\f007";
+@fa-var-user-circle: "\f2bd";
+@fa-var-user-circle-o: "\f2be";
 @fa-var-user-md: "\f0f0";
+@fa-var-user-o: "\f2c0";
 @fa-var-user-plus: "\f234";
 @fa-var-user-secret: "\f21b";
 @fa-var-user-times: "\f235";
 @fa-var-users: "\f0c0";
+@fa-var-vcard: "\f2bb";
+@fa-var-vcard-o: "\f2bc";
 @fa-var-venus: "\f221";
 @fa-var-venus-double: "\f226";
 @fa-var-venus-mars: "\f228";
@@ -722,10 +772,16 @@
 @fa-var-wheelchair-alt: "\f29b";
 @fa-var-wifi: "\f1eb";
 @fa-var-wikipedia-w: "\f266";
+@fa-var-window-close: "\f2d3";
+@fa-var-window-close-o: "\f2d4";
+@fa-var-window-maximize: "\f2d0";
+@fa-var-window-minimize: "\f2d1";
+@fa-var-window-restore: "\f2d2";
 @fa-var-windows: "\f17a";
 @fa-var-won: "\f159";
 @fa-var-wordpress: "\f19a";
 @fa-var-wpbeginner: "\f297";
+@fa-var-wpexplorer: "\f2de";
 @fa-var-wpforms: "\f298";
 @fa-var-wrench: "\f0ad";
 @fa-var-xing: "\f168";

netbox/project-static/font-awesome-4.6.3/scss/_animated.scss → netbox/project-static/font-awesome-4.7.0/scss/_animated.scss


netbox/project-static/font-awesome-4.6.3/scss/_bordered-pulled.scss → netbox/project-static/font-awesome-4.7.0/scss/_bordered-pulled.scss


netbox/project-static/font-awesome-4.6.3/scss/_core.scss → netbox/project-static/font-awesome-4.7.0/scss/_core.scss


netbox/project-static/font-awesome-4.6.3/scss/_fixed-width.scss → netbox/project-static/font-awesome-4.7.0/scss/_fixed-width.scss


+ 56 - 0
netbox/project-static/font-awesome-4.6.3/scss/_icons.scss

@@ -605,6 +605,7 @@
 .#{$fa-css-prefix}-opencart:before { content: $fa-var-opencart; }
 .#{$fa-css-prefix}-expeditedssl:before { content: $fa-var-expeditedssl; }
 .#{$fa-css-prefix}-battery-4:before,
+.#{$fa-css-prefix}-battery:before,
 .#{$fa-css-prefix}-battery-full:before { content: $fa-var-battery-full; }
 .#{$fa-css-prefix}-battery-3:before,
 .#{$fa-css-prefix}-battery-three-quarters:before { content: $fa-var-battery-three-quarters; }
@@ -731,3 +732,58 @@
 .#{$fa-css-prefix}-google-plus-official:before { content: $fa-var-google-plus-official; }
 .#{$fa-css-prefix}-fa:before,
 .#{$fa-css-prefix}-font-awesome:before { content: $fa-var-font-awesome; }
+.#{$fa-css-prefix}-handshake-o:before { content: $fa-var-handshake-o; }
+.#{$fa-css-prefix}-envelope-open:before { content: $fa-var-envelope-open; }
+.#{$fa-css-prefix}-envelope-open-o:before { content: $fa-var-envelope-open-o; }
+.#{$fa-css-prefix}-linode:before { content: $fa-var-linode; }
+.#{$fa-css-prefix}-address-book:before { content: $fa-var-address-book; }
+.#{$fa-css-prefix}-address-book-o:before { content: $fa-var-address-book-o; }
+.#{$fa-css-prefix}-vcard:before,
+.#{$fa-css-prefix}-address-card:before { content: $fa-var-address-card; }
+.#{$fa-css-prefix}-vcard-o:before,
+.#{$fa-css-prefix}-address-card-o:before { content: $fa-var-address-card-o; }
+.#{$fa-css-prefix}-user-circle:before { content: $fa-var-user-circle; }
+.#{$fa-css-prefix}-user-circle-o:before { content: $fa-var-user-circle-o; }
+.#{$fa-css-prefix}-user-o:before { content: $fa-var-user-o; }
+.#{$fa-css-prefix}-id-badge:before { content: $fa-var-id-badge; }
+.#{$fa-css-prefix}-drivers-license:before,
+.#{$fa-css-prefix}-id-card:before { content: $fa-var-id-card; }
+.#{$fa-css-prefix}-drivers-license-o:before,
+.#{$fa-css-prefix}-id-card-o:before { content: $fa-var-id-card-o; }
+.#{$fa-css-prefix}-quora:before { content: $fa-var-quora; }
+.#{$fa-css-prefix}-free-code-camp:before { content: $fa-var-free-code-camp; }
+.#{$fa-css-prefix}-telegram:before { content: $fa-var-telegram; }
+.#{$fa-css-prefix}-thermometer-4:before,
+.#{$fa-css-prefix}-thermometer:before,
+.#{$fa-css-prefix}-thermometer-full:before { content: $fa-var-thermometer-full; }
+.#{$fa-css-prefix}-thermometer-3:before,
+.#{$fa-css-prefix}-thermometer-three-quarters:before { content: $fa-var-thermometer-three-quarters; }
+.#{$fa-css-prefix}-thermometer-2:before,
+.#{$fa-css-prefix}-thermometer-half:before { content: $fa-var-thermometer-half; }
+.#{$fa-css-prefix}-thermometer-1:before,
+.#{$fa-css-prefix}-thermometer-quarter:before { content: $fa-var-thermometer-quarter; }
+.#{$fa-css-prefix}-thermometer-0:before,
+.#{$fa-css-prefix}-thermometer-empty:before { content: $fa-var-thermometer-empty; }
+.#{$fa-css-prefix}-shower:before { content: $fa-var-shower; }
+.#{$fa-css-prefix}-bathtub:before,
+.#{$fa-css-prefix}-s15:before,
+.#{$fa-css-prefix}-bath:before { content: $fa-var-bath; }
+.#{$fa-css-prefix}-podcast:before { content: $fa-var-podcast; }
+.#{$fa-css-prefix}-window-maximize:before { content: $fa-var-window-maximize; }
+.#{$fa-css-prefix}-window-minimize:before { content: $fa-var-window-minimize; }
+.#{$fa-css-prefix}-window-restore:before { content: $fa-var-window-restore; }
+.#{$fa-css-prefix}-times-rectangle:before,
+.#{$fa-css-prefix}-window-close:before { content: $fa-var-window-close; }
+.#{$fa-css-prefix}-times-rectangle-o:before,
+.#{$fa-css-prefix}-window-close-o:before { content: $fa-var-window-close-o; }
+.#{$fa-css-prefix}-bandcamp:before { content: $fa-var-bandcamp; }
+.#{$fa-css-prefix}-grav:before { content: $fa-var-grav; }
+.#{$fa-css-prefix}-etsy:before { content: $fa-var-etsy; }
+.#{$fa-css-prefix}-imdb:before { content: $fa-var-imdb; }
+.#{$fa-css-prefix}-ravelry:before { content: $fa-var-ravelry; }
+.#{$fa-css-prefix}-eercast:before { content: $fa-var-eercast; }
+.#{$fa-css-prefix}-microchip:before { content: $fa-var-microchip; }
+.#{$fa-css-prefix}-snowflake-o:before { content: $fa-var-snowflake-o; }
+.#{$fa-css-prefix}-superpowers:before { content: $fa-var-superpowers; }
+.#{$fa-css-prefix}-wpexplorer:before { content: $fa-var-wpexplorer; }
+.#{$fa-css-prefix}-meetup:before { content: $fa-var-meetup; }

netbox/project-static/font-awesome-4.6.3/scss/_larger.scss → netbox/project-static/font-awesome-4.7.0/scss/_larger.scss


netbox/project-static/font-awesome-4.6.3/scss/_list.scss → netbox/project-static/font-awesome-4.7.0/scss/_list.scss


netbox/project-static/font-awesome-4.6.3/scss/_mixins.scss → netbox/project-static/font-awesome-4.7.0/scss/_mixins.scss


netbox/project-static/font-awesome-4.6.3/scss/_path.scss → netbox/project-static/font-awesome-4.7.0/scss/_path.scss


netbox/project-static/font-awesome-4.6.3/scss/_rotated-flipped.scss → netbox/project-static/font-awesome-4.7.0/scss/_rotated-flipped.scss


netbox/project-static/font-awesome-4.6.3/scss/_screen-reader.scss → netbox/project-static/font-awesome-4.7.0/scss/_screen-reader.scss


+ 0 - 0
netbox/project-static/font-awesome-4.6.3/scss/_stacked.scss


Some files were not shown because too many files changed in this diff