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](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.
 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/
 $ 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
 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.
 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
 # 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.
 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',
         label='Device',
         widget=Livesearch(
         widget=Livesearch(
             query_key='q',
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
             field_to_update='device'
         )
         )
     )
     )
@@ -192,7 +192,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         required=False,
         required=False,
         label='Interface',
         label='Interface',
         widget=APISelect(
         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'
             disabled_indicator='is_connected'
         )
         )
     )
     )

+ 35 - 17
netbox/circuits/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 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
 from .models import Circuit, CircuitType, Provider
 
 
@@ -19,9 +19,7 @@ CIRCUITTYPE_ACTIONS = """
 
 
 class ProviderTable(BaseTable):
 class ProviderTable(BaseTable):
     pk = ToggleColumn()
     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')
     circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
@@ -29,17 +27,25 @@ class ProviderTable(BaseTable):
         fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
         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
 # Circuit types
 #
 #
 
 
 class CircuitTypeTable(BaseTable):
 class CircuitTypeTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
+    name = tables.LinkColumn()
     circuit_count = tables.Column(verbose_name='Circuits')
     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):
     class Meta(BaseTable.Meta):
         model = CircuitType
         model = CircuitType
@@ -52,16 +58,28 @@ class CircuitTypeTable(BaseTable):
 
 
 class CircuitTable(BaseTable):
 class CircuitTable(BaseTable):
     pk = ToggleColumn()
     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):
     class Meta(BaseTable.Meta):
         model = Circuit
         model = Circuit
         fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
         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'device-bays', views.DeviceBayViewSet)
 router.register(r'inventory-items', views.InventoryItemViewSet)
 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)
 router.register(r'interface-connections', views.InterfaceConnectionViewSet)
 
 
 # Miscellaneous
 # 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.permissions import IsAuthenticated
 from rest_framework.response import Response
 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.conf import settings
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
@@ -38,8 +39,8 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
 class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     queryset = Site.objects.select_related('region', 'tenant')
     queryset = Site.objects.select_related('region', 'tenant')
     serializer_class = serializers.SiteSerializer
     serializer_class = serializers.SiteSerializer
-    filter_class = filters.SiteFilter
     write_serializer_class = serializers.WritableSiteSerializer
     write_serializer_class = serializers.WritableSiteSerializer
+    filter_class = filters.SiteFilter
 
 
     @detail_route()
     @detail_route()
     def graphs(self, request, pk=None):
     def graphs(self, request, pk=None):
@@ -59,8 +60,8 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
 class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = RackGroup.objects.select_related('site')
     queryset = RackGroup.objects.select_related('site')
     serializer_class = serializers.RackGroupSerializer
     serializer_class = serializers.RackGroupSerializer
-    filter_class = filters.RackGroupFilter
     write_serializer_class = serializers.WritableRackGroupSerializer
     write_serializer_class = serializers.WritableRackGroupSerializer
+    filter_class = filters.RackGroupFilter
 
 
 
 
 #
 #
@@ -135,6 +136,7 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     queryset = DeviceType.objects.select_related('manufacturer')
     queryset = DeviceType.objects.select_related('manufacturer')
     serializer_class = serializers.DeviceTypeSerializer
     serializer_class = serializers.DeviceTypeSerializer
     write_serializer_class = serializers.WritableDeviceTypeSerializer
     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):
 class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
     queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
     serializer_class = serializers.InterfaceConnectionSerializer
     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 utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     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:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ['name']
+        fields = ['name', 'form_factor']
 
 
 
 
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
@@ -441,10 +441,14 @@ class InterfaceFilter(DeviceComponentFilterSet):
         method='filter_type',
         method='filter_type',
         label='Interface type',
         label='Interface type',
     )
     )
+    mac_address = django_filters.CharFilter(
+        method='_mac_address',
+        label='MAC address',
+    )
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['name']
+        fields = ['name', 'form_factor']
 
 
     def filter_type(self, queryset, name, value):
     def filter_type(self, queryset, name, value):
         value = value.strip().lower()
         value = value.strip().lower()
@@ -456,6 +460,15 @@ class InterfaceFilter(DeviceComponentFilterSet):
             return queryset.filter(form_factor=IFACE_FF_LAG)
             return queryset.filter(form_factor=IFACE_FF_LAG)
         return queryset
         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):
 class DeviceBayFilter(DeviceComponentFilterSet):
 
 
@@ -476,42 +489,66 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
 
     class Meta:
     class Meta:
-        model = ConsoleServerPort
-        fields = []
+        model = ConsolePort
+        fields = ['name', 'connection_status']
 
 
     def filter_site(self, queryset, name, value):
     def filter_site(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(cs_port__device__site__slug=value)
         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):
 class PowerConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
     site = django_filters.CharFilter(
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
 
     class Meta:
     class Meta:
-        model = PowerOutlet
-        fields = []
+        model = PowerPort
+        fields = ['name', 'connection_status']
 
 
     def filter_site(self, queryset, name, value):
     def filter_site(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(power_outlet__device__site__slug=value)
         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):
 class InterfaceConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
     site = django_filters.CharFilter(
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-
-    class Meta:
-        model = InterfaceConnection
-        fields = []
+    device = django_filters.CharFilter(
+        method='filter_device',
+        label='Device',
+    )
 
 
     def filter_site(self, queryset, name, value):
     def filter_site(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -520,3 +557,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             Q(interface_a__device__site__slug=value) |
             Q(interface_a__device__site__slug=value) |
             Q(interface_b__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,
     Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
     Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
     Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
     RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
     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')),
         queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
         to_field_name='slug'
         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',
         label='Console Server',
         widget=Livesearch(
         widget=Livesearch(
             query_key='q',
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='console_server',
             field_to_update='console_server',
         )
         )
     )
     )
@@ -922,7 +937,7 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=ConsoleServerPort.objects.all(),
         queryset=ConsoleServerPort.objects.all(),
         label='Port',
         label='Port',
         widget=APISelect(
         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',
             disabled_indicator='connected_console',
         )
         )
     )
     )
@@ -1015,7 +1030,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
         label='Device',
         label='Device',
         widget=Livesearch(
         widget=Livesearch(
             query_key='q',
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
             field_to_update='device'
         )
         )
     )
     )
@@ -1023,7 +1038,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
         queryset=ConsolePort.objects.all(),
         queryset=ConsolePort.objects.all(),
         label='Port',
         label='Port',
         widget=APISelect(
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/console-ports/',
+            api_url='/api/dcim/console-ports/?device_id={{device}}',
             disabled_indicator='cs_port'
             disabled_indicator='cs_port'
         )
         )
     )
     )
@@ -1182,7 +1197,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         label='PDU',
         label='PDU',
         widget=Livesearch(
         widget=Livesearch(
             query_key='q',
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='pdu'
             field_to_update='pdu'
         )
         )
     )
     )
@@ -1190,7 +1205,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=PowerOutlet.objects.all(),
         queryset=PowerOutlet.objects.all(),
         label='Outlet',
         label='Outlet',
         widget=APISelect(
         widget=APISelect(
-            api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
+            api_url='/api/dcim/power-outlets/?device_id={{device}}',
             disabled_indicator='connected_port'
             disabled_indicator='connected_port'
         )
         )
     )
     )
@@ -1281,7 +1296,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
         label='Device',
         label='Device',
         widget=Livesearch(
         widget=Livesearch(
             query_key='q',
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
             field_to_update='device'
         )
         )
     )
     )
@@ -1289,7 +1304,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
         queryset=PowerPort.objects.all(),
         queryset=PowerPort.objects.all(),
         label='Port',
         label='Port',
         widget=APISelect(
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/power-ports/',
+            api_url='/api/dcim/power-ports/?device_id={{device}}',
             disabled_indicator='power_outlet'
             disabled_indicator='power_outlet'
         )
         )
     )
     )
@@ -1444,7 +1459,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         label='Device',
         label='Device',
         widget=Livesearch(
         widget=Livesearch(
             query_key='q',
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device_b'
             field_to_update='device_b'
         )
         )
     )
     )
@@ -1452,7 +1467,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Interface',
         label='Interface',
         widget=APISelect(
         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'
             disabled_indicator='is_connected'
         )
         )
     )
     )
@@ -1466,7 +1481,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
 
 
         # Initialize interface A choices
         # 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
             form_factor__in=VIRTUAL_IFACE_TYPES
         ).select_related(
         ).select_related(
             'circuit_termination', 'connected_as_a', 'connected_as_b'
             'circuit_termination', 'connected_as_a', 'connected_as_b'
@@ -1643,14 +1658,17 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
 
 
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
+    device = forms.CharField(required=False, label='Device name')
 
 
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
+    device = forms.CharField(required=False, label='Device name')
 
 
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
     site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
+    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 django.utils.encoding import python_2_unicode_compatible
 
 
 from circuits.models import Circuit
 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 extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.fields import ColorField, NullableCharField
 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")
     contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
 
     objects = SiteManager()
     objects = SiteManager()
 
 
@@ -375,6 +376,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
                                      help_text='Units are numbered top-to-bottom')
                                      help_text='Units are numbered top-to-bottom')
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
 
     objects = RackManager()
     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,
     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.
     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.
     creation of a Device.
     """
     """
     device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
     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')
                                        blank=True, null=True, verbose_name='Primary IPv6')
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
 
     objects = DeviceManager()
     objects = DeviceManager()
 
 

+ 91 - 25
netbox/dcim/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
 
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
@@ -100,6 +100,10 @@ DEVICE_PRIMARY_IP = """
 {{ record.primary_ip4.address.ip|default:"" }}
 {{ 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 = """
 UTILIZATION_GRAPH = """
 {% load helpers %}
 {% load helpers %}
 {% utilization_graph value %}
 {% utilization_graph value %}
@@ -132,11 +136,9 @@ class RegionTable(BaseTable):
 
 
 class SiteTable(BaseTable):
 class SiteTable(BaseTable):
     pk = ToggleColumn()
     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')
     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')
     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')
     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
 # Rack groups
 #
 #
@@ -193,20 +205,33 @@ class RackRoleTable(BaseTable):
 
 
 class RackTable(BaseTable):
 class RackTable(BaseTable):
     pk = ToggleColumn()
     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')
     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')
     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')
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Rack
         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):
 class RackImportTable(BaseTable):
@@ -245,15 +270,36 @@ class ManufacturerTable(BaseTable):
 
 
 class DeviceTypeTable(BaseTable):
 class DeviceTypeTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    manufacturer = tables.Column(verbose_name='Manufacturer')
     model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
     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_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')
     instance_count = tables.Column(verbose_name='Instances')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = DeviceType
         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):
 class DeviceTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.TemplateColumn(template_code=DEVICE_LINK)
     status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
     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_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):
     class Meta(BaseTable.Meta):
         model = Device
         model = Device
         fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
         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):
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     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)
         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):
 class InterfaceConnectionTest(HttpStatusMixin, APITestCase):
 
 
     def setUp(self):
     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 ipam.views import ServiceEditView
 from secrets.views import secret_add
 from secrets.views import secret_add
 
 
+from extras.views import ImageAttachmentEditView
+from .models import Device, Rack, Site
 from . import views
 from . import views
 
 
 
 
@@ -22,6 +24,7 @@ urlpatterns = [
     url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
     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-]+)/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<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
     # Rack groups
     url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
     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+)/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<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<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
     # Manufacturers
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     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+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
     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
     # Console ports
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
     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:
             if '_addanother' in request.POST:
                 base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
                 base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
+                device_b = interfaceconnection.interface_b.device
                 params = urlencode({
                 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))
                 return HttpResponseRedirect('{}?{}'.format(base_url, params))
             else:
             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 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
 # 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):
 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 __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)
         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)
         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):
 class CustomFieldChoiceSerializer(serializers.ModelSerializer):
+    """
+    Imitate utilities.api.ChoiceFieldSerializer
+    """
+    value = serializers.IntegerField(source='pk')
+    label = serializers.CharField(source='value')
 
 
     class Meta:
     class Meta:
         model = CustomFieldChoice
         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 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 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
 # User actions
 #
 #
 
 

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

@@ -23,6 +23,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet)
 # Topology maps
 # Topology maps
 router.register(r'topology-maps', views.TopologyMapViewSet)
 router.register(r'topology-maps', views.TopologyMapViewSet)
 
 
+# Image attachments
+router.register(r'image-attachments', views.ImageAttachmentViewSet)
+
 # Recent activity
 # Recent activity
 router.register(r'recent-activity', views.RecentActivityViewSet)
 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 django.shortcuts import get_object_or_404
 
 
 from extras import filters
 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 utilities.api import WritableSerializerMixin
 from . import serializers
 from . import serializers
 
 
@@ -51,7 +51,6 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
 class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
 class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
     serializer_class = serializers.ExportTemplateSerializer
-    # write_serializer_class = serializers.WritableExportTemplateSerializer
     filter_class = filters.ExportTemplateFilter
     filter_class = filters.ExportTemplateFilter
 
 
 
 
@@ -81,6 +80,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
         return response
         return response
 
 
 
 
+class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = ImageAttachment.objects.all()
+    serializer_class = serializers.ImageAttachmentSerializer
+    write_serializer_class = serializers.WritableImageAttachmentSerializer
+
+
 class RecentActivityViewSet(ReadOnlyModelViewSet):
 class RecentActivityViewSet(ReadOnlyModelViewSet):
     """
     """
     List all UserActions to provide a log of recent activity.
     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 import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
-from utilities.forms import BulkEditForm, LaxURLField
+from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
 from .models import (
 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:
         for name, field in custom_fields:
             field.required = False
             field.required = False
             self.fields[name] = field
             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_BULK_EDIT = 4
 ACTION_DELETE = 5
 ACTION_DELETE = 5
 ACTION_BULK_DELETE = 6
 ACTION_BULK_DELETE = 6
+ACTION_BULK_CREATE = 7
 ACTION_CHOICES = (
 ACTION_CHOICES = (
     (ACTION_CREATE, 'created'),
     (ACTION_CREATE, 'created'),
+    (ACTION_BULK_CREATE, 'bulk created'),
     (ACTION_IMPORT, 'imported'),
     (ACTION_IMPORT, 'imported'),
     (ACTION_EDIT, 'modified'),
     (ACTION_EDIT, 'modified'),
     (ACTION_BULK_EDIT, 'bulk edited'),
     (ACTION_BULK_EDIT, 'bulk edited'),
     (ACTION_DELETE, 'deleted'),
     (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
 # User actions
 #
 #
 
 
@@ -396,6 +453,9 @@ class UserActionManager(models.Manager):
     def log_import(self, user, content_type, message=''):
     def log_import(self, user, content_type, message=''):
         self.log_bulk_action(user, content_type, ACTION_IMPORT, 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=''):
     def log_bulk_edit(self, user, content_type, message=''):
         self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, 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)
         return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
 
 
     def icon(self):
     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>')
             return mark_safe('<i class="glyphicon glyphicon-plus text-success"></i>')
         elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]:
         elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]:
             return mark_safe('<i class="glyphicon glyphicon-pencil text-warning"></i>')
             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):
 class RIRViewSet(ModelViewSet):
     queryset = RIR.objects.all()
     queryset = RIR.objects.all()
     serializer_class = serializers.RIRSerializer
     serializer_class = serializers.RIRSerializer
+    filter_class = filters.RIRFilter
 
 
 
 
 #
 #
@@ -99,3 +100,4 @@ class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = Service.objects.select_related('device')
     queryset = Service.objects.select_related('device')
     serializer_class = serializers.ServiceSerializer
     serializer_class = serializers.ServiceSerializer
     write_serializer_class = serializers.WritableServiceSerializer
     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',
                                                          display_field='display_name',
                                                          attrs={'filter-for': 'nat_inside'}))
                                                          attrs={'filter-for': 'nat_inside'}))
     livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
     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:
     class Meta:
@@ -404,7 +404,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
         label='Device',
         label='Device',
         widget=Livesearch(
         widget=Livesearch(
             query_key='q',
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
             field_to_update='device'
         )
         )
     )
     )
@@ -412,7 +412,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Interface',
         label='Interface',
         widget=APISelect(
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/interfaces/'
+            api_url='/api/dcim/interfaces/?device_id={{device}}'
         )
         )
     )
     )
     set_as_primary = forms.BooleanField(
     set_as_primary = forms.BooleanField(

+ 86 - 36
netbox/ipam/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 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
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
@@ -133,16 +133,25 @@ TENANT_LINK = """
 
 
 class VRFTable(BaseTable):
 class VRFTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
+    name = tables.LinkColumn()
     rd = tables.Column(verbose_name='RD')
     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):
     class Meta(BaseTable.Meta):
         model = VRF
         model = VRF
         fields = ('pk', 'name', 'rd', 'tenant', 'description')
         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
 # RIRs
 #
 #
@@ -177,18 +186,25 @@ class RIRTable(BaseTable):
 
 
 class AggregateTable(BaseTable):
 class AggregateTable(BaseTable):
     pk = ToggleColumn()
     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')
     child_count = tables.Column(verbose_name='Prefixes')
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
     date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
-    description = tables.Column(verbose_name='Description')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Aggregate
         model = Aggregate
         fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
         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
 # Roles
 #
 #
@@ -212,14 +228,13 @@ class RoleTable(BaseTable):
 
 
 class PrefixTable(BaseTable):
 class PrefixTable(BaseTable):
     pk = ToggleColumn()
     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')
     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')
     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):
     class Meta(BaseTable.Meta):
         model = Prefix
         model = Prefix
@@ -230,12 +245,11 @@ class PrefixTable(BaseTable):
 
 
 
 
 class PrefixBriefTable(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):
     class Meta(BaseTable.Meta):
         model = Prefix
         model = Prefix
@@ -243,6 +257,20 @@ class PrefixBriefTable(BaseTable):
         orderable = False
         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
 # IPAddresses
 #
 #
@@ -250,13 +278,11 @@ class PrefixBriefTable(BaseTable):
 class IPAddressTable(BaseTable):
 class IPAddressTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
     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')
     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):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
@@ -268,17 +294,30 @@ class IPAddressTable(BaseTable):
 
 
 class IPAddressBriefTable(BaseTable):
 class IPAddressBriefTable(BaseTable):
     address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
     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):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = ('address', 'device', 'interface', 'nat_inside')
         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
 # VLAN groups
 #
 #
@@ -304,15 +343,26 @@ class VLANGroupTable(BaseTable):
 class VLANTable(BaseTable):
 class VLANTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
     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')
     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')
     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):
     class Meta(BaseTable.Meta):
         model = VLAN
         model = VLAN
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
         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
 from django_tables2 import RequestConfig
 import netaddr
 import netaddr
 
 
+from django.conf import settings
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib import messages
 from django.contrib import messages
@@ -295,7 +296,12 @@ def aggregate(request, pk):
     prefix_table = tables.PrefixTable(child_prefixes)
     prefix_table = tables.PrefixTable(child_prefixes)
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
         prefix_table.base_columns['pk'].visible = True
         prefix_table.base_columns['pk'].visible = True
-    RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
+
+    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
     # Compile permissions list for rendering the object table
     permissions = {
     permissions = {
@@ -427,7 +433,12 @@ def prefix(request, pk):
     child_prefix_table = tables.PrefixTable(child_prefixes)
     child_prefix_table = tables.PrefixTable(child_prefixes)
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
     if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
         child_prefix_table.base_columns['pk'].visible = True
         child_prefix_table.base_columns['pk'].visible = True
-    RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
+
+    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
     # Compile permissions list for rendering the object table
     permissions = {
     permissions = {
@@ -500,7 +511,12 @@ def prefix_ipaddresses(request, pk):
     ip_table = tables.IPAddressTable(ipaddresses)
     ip_table = tables.IPAddressTable(ipaddresses)
     if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
     if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
         ip_table.base_columns['pk'].visible = True
         ip_table.base_columns['pk'].visible = True
-    RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
+
+    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
     # Compile permissions list for rendering the object table
     permissions = {
     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
 # Import local configuration
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
@@ -153,6 +153,7 @@ TEMPLATES = [
             'context_processors': [
             'context_processors': [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',
                 'django.template.context_processors.request',
+                'django.template.context_processors.media',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 'django.contrib.messages.context_processors.messages',
                 'utilities.context_processors.settings',
                 'utilities.context_processors.settings',
@@ -167,19 +168,21 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 USE_X_FORWARDED_HOST = True
 USE_X_FORWARDED_HOST = True
 
 
 # Internationalization
 # Internationalization
-# https://docs.djangoproject.com/en/1.8/topics/i18n/
 LANGUAGE_CODE = 'en-us'
 LANGUAGE_CODE = 'en-us'
 USE_I18N = True
 USE_I18N = True
 USE_TZ = True
 USE_TZ = True
 
 
 # Static files (CSS, JavaScript, Images)
 # Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/1.8/howto/static-files/
 STATIC_ROOT = BASE_DIR + '/static/'
 STATIC_ROOT = BASE_DIR + '/static/'
 STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATICFILES_DIRS = (
 STATICFILES_DIRS = (
     os.path.join(BASE_DIR, "project-static"),
     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.)
 # 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
 DATA_UPLOAD_MAX_NUMBER_FIELDS = None
 
 
@@ -215,6 +218,14 @@ REST_FRAMEWORK = {
 }
 }
 
 
 # Django debug toolbar
 # 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 = (
 INTERNAL_IPS = (
     '127.0.0.1',
     '127.0.0.1',
     '::1',
     '::1',

+ 8 - 2
netbox/netbox/urls.py

@@ -1,8 +1,9 @@
 from django.conf import settings
 from django.conf import settings
 from django.conf.urls import include, url
 from django.conf.urls import include, url
 from django.contrib import admin
 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
 from users.views import login, logout
 
 
 
 
@@ -10,8 +11,9 @@ handler500 = handle_500
 
 
 _patterns = [
 _patterns = [
 
 
-    # Default page
+    # Base views
     url(r'^$', home, name='home'),
     url(r'^$', home, name='home'),
+    url(r'^search/$', SearchView.as_view(), name='search'),
 
 
     # Login/logout
     # Login/logout
     url(r'^login/$', login, name='login'),
     url(r'^login/$', login, name='login'),
@@ -20,6 +22,7 @@ _patterns = [
     # Apps
     # Apps
     url(r'^circuits/', include('circuits.urls', namespace='circuits')),
     url(r'^circuits/', include('circuits.urls', namespace='circuits')),
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
     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'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
     url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
     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/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
     url(r'^api/docs/', include('rest_framework_swagger.urls')),
     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
     # Error testing
     url(r'^500/$', trigger_500),
     url(r'^500/$', trigger_500),
 
 

+ 152 - 5
netbox/netbox/views.py

@@ -1,18 +1,117 @@
 import sys
 import sys
 
 
-from rest_framework.permissions import IsAuthenticated
 from rest_framework.views import APIView
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
 
 
 from django.shortcuts import render
 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 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.models import Secret
+from secrets.tables import SecretSearchTable
+from tenancy.filters import TenantFilter
 from tenancy.models import Tenant
 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):
 def home(request):
@@ -47,11 +146,59 @@ def home(request):
     }
     }
 
 
     return render(request, 'home.html', {
     return render(request, 'home.html', {
+        'search_form': SearchForm(),
         'stats': stats,
         'stats': stats,
         'recent_activity': UserAction.objects.select_related('user')[:50]
         '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):
 class APIRootView(APIView):
     _ignore_model_permissions = True
     _ignore_model_permissions = True
     exclude_from_schema = 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  */
  */
 .btn-default,
 .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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  */
  */
 /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
 /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
@@ -1106,7 +1106,6 @@ a:focus {
   text-decoration: underline;
   text-decoration: underline;
 }
 }
 a:focus {
 a:focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
   outline-offset: -2px;
 }
 }
@@ -2537,7 +2536,6 @@ select[size] {
 input[type="file"]:focus,
 input[type="file"]:focus,
 input[type="radio"]:focus,
 input[type="radio"]:focus,
 input[type="checkbox"]:focus {
 input[type="checkbox"]:focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
   outline-offset: -2px;
 }
 }
@@ -3029,7 +3027,6 @@ select[multiple].input-lg {
 .btn.focus,
 .btn.focus,
 .btn:active.focus,
 .btn:active.focus,
 .btn.active.focus {
 .btn.active.focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
   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
  * Licensed under the MIT license
  */
  */
 
 
@@ -11,16 +11,16 @@ if (typeof jQuery === 'undefined') {
 +function ($) {
 +function ($) {
   'use strict';
   'use strict';
   var version = $.fn.jquery.split(' ')[0].split('.')
   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);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: transition.js v3.3.6
+ * Bootstrap: transition.js v3.3.7
  * http://getbootstrap.com/javascript/#transitions
  * 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -77,10 +77,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: alert.js v3.3.6
+ * Bootstrap: alert.js v3.3.7
  * http://getbootstrap.com/javascript/#alerts
  * 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)
  * 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)
     $(el).on('click', dismiss, this.close)
   }
   }
 
 
-  Alert.VERSION = '3.3.6'
+  Alert.VERSION = '3.3.7'
 
 
   Alert.TRANSITION_DURATION = 150
   Alert.TRANSITION_DURATION = 150
 
 
@@ -109,7 +109,7 @@ if (typeof jQuery === 'undefined') {
       selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
       selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
     }
     }
 
 
-    var $parent = $(selector)
+    var $parent = $(selector === '#' ? [] : selector)
 
 
     if (e) e.preventDefault()
     if (e) e.preventDefault()
 
 
@@ -172,10 +172,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: button.js v3.3.6
+ * Bootstrap: button.js v3.3.7
  * http://getbootstrap.com/javascript/#buttons
  * 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -192,7 +192,7 @@ if (typeof jQuery === 'undefined') {
     this.isLoading = false
     this.isLoading = false
   }
   }
 
 
-  Button.VERSION  = '3.3.6'
+  Button.VERSION  = '3.3.7'
 
 
   Button.DEFAULTS = {
   Button.DEFAULTS = {
     loadingText: 'loading...'
     loadingText: 'loading...'
@@ -214,10 +214,10 @@ if (typeof jQuery === 'undefined') {
 
 
       if (state == 'loadingText') {
       if (state == 'loadingText') {
         this.isLoading = true
         this.isLoading = true
-        $el.addClass(d).attr(d, d)
+        $el.addClass(d).attr(d, d).prop(d, true)
       } else if (this.isLoading) {
       } else if (this.isLoading) {
         this.isLoading = false
         this.isLoading = false
-        $el.removeClass(d).removeAttr(d)
+        $el.removeClass(d).removeAttr(d).prop(d, false)
       }
       }
     }, this), 0)
     }, this), 0)
   }
   }
@@ -281,10 +281,15 @@ if (typeof jQuery === 'undefined') {
 
 
   $(document)
   $(document)
     .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) {
     .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')
       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) {
     .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))
       $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type))
@@ -293,10 +298,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: carousel.js v3.3.6
+ * Bootstrap: carousel.js v3.3.7
  * http://getbootstrap.com/javascript/#carousel
  * 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)
  * 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))
       .on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
   }
   }
 
 
-  Carousel.VERSION  = '3.3.6'
+  Carousel.VERSION  = '3.3.7'
 
 
   Carousel.TRANSITION_DURATION = 600
   Carousel.TRANSITION_DURATION = 600
 
 
@@ -531,13 +536,14 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: collapse.js v3.3.6
+ * Bootstrap: collapse.js v3.3.7
  * http://getbootstrap.com/javascript/#collapse
  * 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
+/* jshint latedef: false */
 
 
 +function ($) {
 +function ($) {
   'use strict';
   'use strict';
@@ -561,7 +567,7 @@ if (typeof jQuery === 'undefined') {
     if (this.options.toggle) this.toggle()
     if (this.options.toggle) this.toggle()
   }
   }
 
 
-  Collapse.VERSION  = '3.3.6'
+  Collapse.VERSION  = '3.3.7'
 
 
   Collapse.TRANSITION_DURATION = 350
   Collapse.TRANSITION_DURATION = 350
 
 
@@ -743,10 +749,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: dropdown.js v3.3.6
+ * Bootstrap: dropdown.js v3.3.7
  * http://getbootstrap.com/javascript/#dropdowns
  * 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)
  * 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)
     $(element).on('click.bs.dropdown', this.toggle)
   }
   }
 
 
-  Dropdown.VERSION = '3.3.6'
+  Dropdown.VERSION = '3.3.7'
 
 
   function getParent($this) {
   function getParent($this) {
     var selector = $this.attr('data-target')
     var selector = $this.attr('data-target')
@@ -909,10 +915,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: modal.js v3.3.6
+ * Bootstrap: modal.js v3.3.7
  * http://getbootstrap.com/javascript/#modals
  * 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)
  * 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.TRANSITION_DURATION = 300
   Modal.BACKDROP_TRANSITION_DURATION = 150
   Modal.BACKDROP_TRANSITION_DURATION = 150
@@ -1050,7 +1056,9 @@ if (typeof jQuery === 'undefined') {
     $(document)
     $(document)
       .off('focusin.bs.modal') // guard against infinite focus loop
       .off('focusin.bs.modal') // guard against infinite focus loop
       .on('focusin.bs.modal', $.proxy(function (e) {
       .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.$element.trigger('focus')
         }
         }
       }, this))
       }, this))
@@ -1247,11 +1255,11 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: tooltip.js v3.3.6
+ * Bootstrap: tooltip.js v3.3.7
  * http://getbootstrap.com/javascript/#tooltip
  * http://getbootstrap.com/javascript/#tooltip
  * Inspired by the original jQuery.tipsy by Jason Frame
  * 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -1274,7 +1282,7 @@ if (typeof jQuery === 'undefined') {
     this.init('tooltip', element, options)
     this.init('tooltip', element, options)
   }
   }
 
 
-  Tooltip.VERSION  = '3.3.6'
+  Tooltip.VERSION  = '3.3.7'
 
 
   Tooltip.TRANSITION_DURATION = 150
   Tooltip.TRANSITION_DURATION = 150
 
 
@@ -1565,9 +1573,11 @@ if (typeof jQuery === 'undefined') {
 
 
     function complete() {
     function complete() {
       if (that.hoverState != 'in') $tip.detach()
       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()
       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
       // 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 })
       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 scroll    = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() }
     var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null
     var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null
 
 
@@ -1726,6 +1739,7 @@ if (typeof jQuery === 'undefined') {
       that.$tip = null
       that.$tip = null
       that.$arrow = null
       that.$arrow = null
       that.$viewport = null
       that.$viewport = null
+      that.$element = null
     })
     })
   }
   }
 
 
@@ -1762,10 +1776,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: popover.js v3.3.6
+ * Bootstrap: popover.js v3.3.7
  * http://getbootstrap.com/javascript/#popovers
  * 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)
  * 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')
   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, {
   Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
     placement: 'right',
     placement: 'right',
@@ -1871,10 +1885,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: scrollspy.js v3.3.6
+ * Bootstrap: scrollspy.js v3.3.7
  * http://getbootstrap.com/javascript/#scrollspy
  * 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -1900,7 +1914,7 @@ if (typeof jQuery === 'undefined') {
     this.process()
     this.process()
   }
   }
 
 
-  ScrollSpy.VERSION  = '3.3.6'
+  ScrollSpy.VERSION  = '3.3.7'
 
 
   ScrollSpy.DEFAULTS = {
   ScrollSpy.DEFAULTS = {
     offset: 10
     offset: 10
@@ -2044,10 +2058,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: tab.js v3.3.6
+ * Bootstrap: tab.js v3.3.7
  * http://getbootstrap.com/javascript/#tabs
  * 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -2064,7 +2078,7 @@ if (typeof jQuery === 'undefined') {
     // jscs:enable requireDollarBeforejQueryAssignment
     // jscs:enable requireDollarBeforejQueryAssignment
   }
   }
 
 
-  Tab.VERSION = '3.3.6'
+  Tab.VERSION = '3.3.7'
 
 
   Tab.TRANSITION_DURATION = 150
   Tab.TRANSITION_DURATION = 150
 
 
@@ -2200,10 +2214,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: affix.js v3.3.6
+ * Bootstrap: affix.js v3.3.7
  * http://getbootstrap.com/javascript/#affix
  * 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)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -2229,7 +2243,7 @@ if (typeof jQuery === 'undefined') {
     this.checkPosition()
     this.checkPosition()
   }
   }
 
 
-  Affix.VERSION  = '3.3.6'
+  Affix.VERSION  = '3.3.7'
 
 
   Affix.RESET    = 'affix affix-top affix-bottom'
   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) {
 table.attr-table td:nth-child(1) {
     width: 25%;
     width: 25%;
 }
 }
+.table-headings th {
+    background-color: #f5f5f5;
+}
 
 
 /* Paginator */
 /* Paginator */
 div.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)
  *  License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
  */
  */
 /* FONT PATH
 /* FONT PATH
  * -------------------------- */
  * -------------------------- */
 @font-face {
 @font-face {
   font-family: 'FontAwesome';
   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-weight: normal;
   font-style: normal;
   font-style: normal;
 }
 }
@@ -1832,6 +1832,7 @@
   content: "\f23e";
   content: "\f23e";
 }
 }
 .fa-battery-4:before,
 .fa-battery-4:before,
+.fa-battery:before,
 .fa-battery-full:before {
 .fa-battery-full:before {
   content: "\f240";
   content: "\f240";
 }
 }
@@ -2178,6 +2179,143 @@
 .fa-font-awesome:before {
 .fa-font-awesome:before {
   content: "\f2b4";
   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 {
 .sr-only {
   position: absolute;
   position: absolute;
   width: 1px;
   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)
  *  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}-opencart:before { content: @fa-var-opencart; }
 .@{fa-css-prefix}-expeditedssl:before { content: @fa-var-expeditedssl; }
 .@{fa-css-prefix}-expeditedssl:before { content: @fa-var-expeditedssl; }
 .@{fa-css-prefix}-battery-4:before,
 .@{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-full:before { content: @fa-var-battery-full; }
 .@{fa-css-prefix}-battery-3:before,
 .@{fa-css-prefix}-battery-3:before,
 .@{fa-css-prefix}-battery-three-quarters:before { content: @fa-var-battery-three-quarters; }
 .@{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}-google-plus-official:before { content: @fa-var-google-plus-official; }
 .@{fa-css-prefix}-fa:before,
 .@{fa-css-prefix}-fa:before,
 .@{fa-css-prefix}-font-awesome:before { content: @fa-var-font-awesome; }
 .@{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-path:        "../fonts";
 @fa-font-size-base:   14px;
 @fa-font-size-base:   14px;
 @fa-line-height-base: 1;
 @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-css-prefix:       fa;
-@fa-version:          "4.6.3";
+@fa-version:          "4.7.0";
 @fa-border-color:     #eee;
 @fa-border-color:     #eee;
 @fa-inverse:          #fff;
 @fa-inverse:          #fff;
 @fa-li-width:         (30em / 14);
 @fa-li-width:         (30em / 14);
 
 
 @fa-var-500px: "\f26e";
 @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-adjust: "\f042";
 @fa-var-adn: "\f170";
 @fa-var-adn: "\f170";
 @fa-var-align-center: "\f037";
 @fa-var-align-center: "\f037";
@@ -60,11 +64,15 @@
 @fa-var-backward: "\f04a";
 @fa-var-backward: "\f04a";
 @fa-var-balance-scale: "\f24e";
 @fa-var-balance-scale: "\f24e";
 @fa-var-ban: "\f05e";
 @fa-var-ban: "\f05e";
+@fa-var-bandcamp: "\f2d5";
 @fa-var-bank: "\f19c";
 @fa-var-bank: "\f19c";
 @fa-var-bar-chart: "\f080";
 @fa-var-bar-chart: "\f080";
 @fa-var-bar-chart-o: "\f080";
 @fa-var-bar-chart-o: "\f080";
 @fa-var-barcode: "\f02a";
 @fa-var-barcode: "\f02a";
 @fa-var-bars: "\f0c9";
 @fa-var-bars: "\f0c9";
+@fa-var-bath: "\f2cd";
+@fa-var-bathtub: "\f2cd";
+@fa-var-battery: "\f240";
 @fa-var-battery-0: "\f244";
 @fa-var-battery-0: "\f244";
 @fa-var-battery-1: "\f243";
 @fa-var-battery-1: "\f243";
 @fa-var-battery-2: "\f242";
 @fa-var-battery-2: "\f242";
@@ -214,19 +222,25 @@
 @fa-var-dot-circle-o: "\f192";
 @fa-var-dot-circle-o: "\f192";
 @fa-var-download: "\f019";
 @fa-var-download: "\f019";
 @fa-var-dribbble: "\f17d";
 @fa-var-dribbble: "\f17d";
+@fa-var-drivers-license: "\f2c2";
+@fa-var-drivers-license-o: "\f2c3";
 @fa-var-dropbox: "\f16b";
 @fa-var-dropbox: "\f16b";
 @fa-var-drupal: "\f1a9";
 @fa-var-drupal: "\f1a9";
 @fa-var-edge: "\f282";
 @fa-var-edge: "\f282";
 @fa-var-edit: "\f044";
 @fa-var-edit: "\f044";
+@fa-var-eercast: "\f2da";
 @fa-var-eject: "\f052";
 @fa-var-eject: "\f052";
 @fa-var-ellipsis-h: "\f141";
 @fa-var-ellipsis-h: "\f141";
 @fa-var-ellipsis-v: "\f142";
 @fa-var-ellipsis-v: "\f142";
 @fa-var-empire: "\f1d1";
 @fa-var-empire: "\f1d1";
 @fa-var-envelope: "\f0e0";
 @fa-var-envelope: "\f0e0";
 @fa-var-envelope-o: "\f003";
 @fa-var-envelope-o: "\f003";
+@fa-var-envelope-open: "\f2b6";
+@fa-var-envelope-open-o: "\f2b7";
 @fa-var-envelope-square: "\f199";
 @fa-var-envelope-square: "\f199";
 @fa-var-envira: "\f299";
 @fa-var-envira: "\f299";
 @fa-var-eraser: "\f12d";
 @fa-var-eraser: "\f12d";
+@fa-var-etsy: "\f2d7";
 @fa-var-eur: "\f153";
 @fa-var-eur: "\f153";
 @fa-var-euro: "\f153";
 @fa-var-euro: "\f153";
 @fa-var-exchange: "\f0ec";
 @fa-var-exchange: "\f0ec";
@@ -294,6 +308,7 @@
 @fa-var-forumbee: "\f211";
 @fa-var-forumbee: "\f211";
 @fa-var-forward: "\f04e";
 @fa-var-forward: "\f04e";
 @fa-var-foursquare: "\f180";
 @fa-var-foursquare: "\f180";
+@fa-var-free-code-camp: "\f2c5";
 @fa-var-frown-o: "\f119";
 @fa-var-frown-o: "\f119";
 @fa-var-futbol-o: "\f1e3";
 @fa-var-futbol-o: "\f1e3";
 @fa-var-gamepad: "\f11b";
 @fa-var-gamepad: "\f11b";
@@ -326,6 +341,7 @@
 @fa-var-google-wallet: "\f1ee";
 @fa-var-google-wallet: "\f1ee";
 @fa-var-graduation-cap: "\f19d";
 @fa-var-graduation-cap: "\f19d";
 @fa-var-gratipay: "\f184";
 @fa-var-gratipay: "\f184";
+@fa-var-grav: "\f2d6";
 @fa-var-group: "\f0c0";
 @fa-var-group: "\f0c0";
 @fa-var-h-square: "\f0fd";
 @fa-var-h-square: "\f0fd";
 @fa-var-hacker-news: "\f1d4";
 @fa-var-hacker-news: "\f1d4";
@@ -342,6 +358,7 @@
 @fa-var-hand-scissors-o: "\f257";
 @fa-var-hand-scissors-o: "\f257";
 @fa-var-hand-spock-o: "\f259";
 @fa-var-hand-spock-o: "\f259";
 @fa-var-hand-stop-o: "\f256";
 @fa-var-hand-stop-o: "\f256";
+@fa-var-handshake-o: "\f2b5";
 @fa-var-hard-of-hearing: "\f2a4";
 @fa-var-hard-of-hearing: "\f2a4";
 @fa-var-hashtag: "\f292";
 @fa-var-hashtag: "\f292";
 @fa-var-hdd-o: "\f0a0";
 @fa-var-hdd-o: "\f0a0";
@@ -365,8 +382,12 @@
 @fa-var-houzz: "\f27c";
 @fa-var-houzz: "\f27c";
 @fa-var-html5: "\f13b";
 @fa-var-html5: "\f13b";
 @fa-var-i-cursor: "\f246";
 @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-ils: "\f20b";
 @fa-var-image: "\f03e";
 @fa-var-image: "\f03e";
+@fa-var-imdb: "\f2d8";
 @fa-var-inbox: "\f01c";
 @fa-var-inbox: "\f01c";
 @fa-var-indent: "\f03c";
 @fa-var-indent: "\f03c";
 @fa-var-industry: "\f275";
 @fa-var-industry: "\f275";
@@ -404,6 +425,7 @@
 @fa-var-link: "\f0c1";
 @fa-var-link: "\f0c1";
 @fa-var-linkedin: "\f0e1";
 @fa-var-linkedin: "\f0e1";
 @fa-var-linkedin-square: "\f08c";
 @fa-var-linkedin-square: "\f08c";
+@fa-var-linode: "\f2b8";
 @fa-var-linux: "\f17c";
 @fa-var-linux: "\f17c";
 @fa-var-list: "\f03a";
 @fa-var-list: "\f03a";
 @fa-var-list-alt: "\f022";
 @fa-var-list-alt: "\f022";
@@ -436,8 +458,10 @@
 @fa-var-meanpath: "\f20c";
 @fa-var-meanpath: "\f20c";
 @fa-var-medium: "\f23a";
 @fa-var-medium: "\f23a";
 @fa-var-medkit: "\f0fa";
 @fa-var-medkit: "\f0fa";
+@fa-var-meetup: "\f2e0";
 @fa-var-meh-o: "\f11a";
 @fa-var-meh-o: "\f11a";
 @fa-var-mercury: "\f223";
 @fa-var-mercury: "\f223";
+@fa-var-microchip: "\f2db";
 @fa-var-microphone: "\f130";
 @fa-var-microphone: "\f130";
 @fa-var-microphone-slash: "\f131";
 @fa-var-microphone-slash: "\f131";
 @fa-var-minus: "\f068";
 @fa-var-minus: "\f068";
@@ -502,6 +526,7 @@
 @fa-var-plus-circle: "\f055";
 @fa-var-plus-circle: "\f055";
 @fa-var-plus-square: "\f0fe";
 @fa-var-plus-square: "\f0fe";
 @fa-var-plus-square-o: "\f196";
 @fa-var-plus-square-o: "\f196";
+@fa-var-podcast: "\f2ce";
 @fa-var-power-off: "\f011";
 @fa-var-power-off: "\f011";
 @fa-var-print: "\f02f";
 @fa-var-print: "\f02f";
 @fa-var-product-hunt: "\f288";
 @fa-var-product-hunt: "\f288";
@@ -511,10 +536,12 @@
 @fa-var-question: "\f128";
 @fa-var-question: "\f128";
 @fa-var-question-circle: "\f059";
 @fa-var-question-circle: "\f059";
 @fa-var-question-circle-o: "\f29c";
 @fa-var-question-circle-o: "\f29c";
+@fa-var-quora: "\f2c4";
 @fa-var-quote-left: "\f10d";
 @fa-var-quote-left: "\f10d";
 @fa-var-quote-right: "\f10e";
 @fa-var-quote-right: "\f10e";
 @fa-var-ra: "\f1d0";
 @fa-var-ra: "\f1d0";
 @fa-var-random: "\f074";
 @fa-var-random: "\f074";
+@fa-var-ravelry: "\f2d9";
 @fa-var-rebel: "\f1d0";
 @fa-var-rebel: "\f1d0";
 @fa-var-recycle: "\f1b8";
 @fa-var-recycle: "\f1b8";
 @fa-var-reddit: "\f1a1";
 @fa-var-reddit: "\f1a1";
@@ -541,6 +568,7 @@
 @fa-var-rub: "\f158";
 @fa-var-rub: "\f158";
 @fa-var-ruble: "\f158";
 @fa-var-ruble: "\f158";
 @fa-var-rupee: "\f156";
 @fa-var-rupee: "\f156";
+@fa-var-s15: "\f2cd";
 @fa-var-safari: "\f267";
 @fa-var-safari: "\f267";
 @fa-var-save: "\f0c7";
 @fa-var-save: "\f0c7";
 @fa-var-scissors: "\f0c4";
 @fa-var-scissors: "\f0c4";
@@ -565,6 +593,7 @@
 @fa-var-shopping-bag: "\f290";
 @fa-var-shopping-bag: "\f290";
 @fa-var-shopping-basket: "\f291";
 @fa-var-shopping-basket: "\f291";
 @fa-var-shopping-cart: "\f07a";
 @fa-var-shopping-cart: "\f07a";
+@fa-var-shower: "\f2cc";
 @fa-var-sign-in: "\f090";
 @fa-var-sign-in: "\f090";
 @fa-var-sign-language: "\f2a7";
 @fa-var-sign-language: "\f2a7";
 @fa-var-sign-out: "\f08b";
 @fa-var-sign-out: "\f08b";
@@ -581,6 +610,7 @@
 @fa-var-snapchat: "\f2ab";
 @fa-var-snapchat: "\f2ab";
 @fa-var-snapchat-ghost: "\f2ac";
 @fa-var-snapchat-ghost: "\f2ac";
 @fa-var-snapchat-square: "\f2ad";
 @fa-var-snapchat-square: "\f2ad";
+@fa-var-snowflake-o: "\f2dc";
 @fa-var-soccer-ball-o: "\f1e3";
 @fa-var-soccer-ball-o: "\f1e3";
 @fa-var-sort: "\f0dc";
 @fa-var-sort: "\f0dc";
 @fa-var-sort-alpha-asc: "\f15d";
 @fa-var-sort-alpha-asc: "\f15d";
@@ -626,6 +656,7 @@
 @fa-var-subway: "\f239";
 @fa-var-subway: "\f239";
 @fa-var-suitcase: "\f0f2";
 @fa-var-suitcase: "\f0f2";
 @fa-var-sun-o: "\f185";
 @fa-var-sun-o: "\f185";
+@fa-var-superpowers: "\f2dd";
 @fa-var-superscript: "\f12b";
 @fa-var-superscript: "\f12b";
 @fa-var-support: "\f1cd";
 @fa-var-support: "\f1cd";
 @fa-var-table: "\f0ce";
 @fa-var-table: "\f0ce";
@@ -635,6 +666,7 @@
 @fa-var-tags: "\f02c";
 @fa-var-tags: "\f02c";
 @fa-var-tasks: "\f0ae";
 @fa-var-tasks: "\f0ae";
 @fa-var-taxi: "\f1ba";
 @fa-var-taxi: "\f1ba";
+@fa-var-telegram: "\f2c6";
 @fa-var-television: "\f26c";
 @fa-var-television: "\f26c";
 @fa-var-tencent-weibo: "\f1d5";
 @fa-var-tencent-weibo: "\f1d5";
 @fa-var-terminal: "\f120";
 @fa-var-terminal: "\f120";
@@ -644,6 +676,17 @@
 @fa-var-th-large: "\f009";
 @fa-var-th-large: "\f009";
 @fa-var-th-list: "\f00b";
 @fa-var-th-list: "\f00b";
 @fa-var-themeisle: "\f2b2";
 @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-thumb-tack: "\f08d";
 @fa-var-thumbs-down: "\f165";
 @fa-var-thumbs-down: "\f165";
 @fa-var-thumbs-o-down: "\f088";
 @fa-var-thumbs-o-down: "\f088";
@@ -653,6 +696,8 @@
 @fa-var-times: "\f00d";
 @fa-var-times: "\f00d";
 @fa-var-times-circle: "\f057";
 @fa-var-times-circle: "\f057";
 @fa-var-times-circle-o: "\f05c";
 @fa-var-times-circle-o: "\f05c";
+@fa-var-times-rectangle: "\f2d3";
+@fa-var-times-rectangle-o: "\f2d4";
 @fa-var-tint: "\f043";
 @fa-var-tint: "\f043";
 @fa-var-toggle-down: "\f150";
 @fa-var-toggle-down: "\f150";
 @fa-var-toggle-left: "\f191";
 @fa-var-toggle-left: "\f191";
@@ -693,11 +738,16 @@
 @fa-var-usb: "\f287";
 @fa-var-usb: "\f287";
 @fa-var-usd: "\f155";
 @fa-var-usd: "\f155";
 @fa-var-user: "\f007";
 @fa-var-user: "\f007";
+@fa-var-user-circle: "\f2bd";
+@fa-var-user-circle-o: "\f2be";
 @fa-var-user-md: "\f0f0";
 @fa-var-user-md: "\f0f0";
+@fa-var-user-o: "\f2c0";
 @fa-var-user-plus: "\f234";
 @fa-var-user-plus: "\f234";
 @fa-var-user-secret: "\f21b";
 @fa-var-user-secret: "\f21b";
 @fa-var-user-times: "\f235";
 @fa-var-user-times: "\f235";
 @fa-var-users: "\f0c0";
 @fa-var-users: "\f0c0";
+@fa-var-vcard: "\f2bb";
+@fa-var-vcard-o: "\f2bc";
 @fa-var-venus: "\f221";
 @fa-var-venus: "\f221";
 @fa-var-venus-double: "\f226";
 @fa-var-venus-double: "\f226";
 @fa-var-venus-mars: "\f228";
 @fa-var-venus-mars: "\f228";
@@ -722,10 +772,16 @@
 @fa-var-wheelchair-alt: "\f29b";
 @fa-var-wheelchair-alt: "\f29b";
 @fa-var-wifi: "\f1eb";
 @fa-var-wifi: "\f1eb";
 @fa-var-wikipedia-w: "\f266";
 @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-windows: "\f17a";
 @fa-var-won: "\f159";
 @fa-var-won: "\f159";
 @fa-var-wordpress: "\f19a";
 @fa-var-wordpress: "\f19a";
 @fa-var-wpbeginner: "\f297";
 @fa-var-wpbeginner: "\f297";
+@fa-var-wpexplorer: "\f2de";
 @fa-var-wpforms: "\f298";
 @fa-var-wpforms: "\f298";
 @fa-var-wrench: "\f0ad";
 @fa-var-wrench: "\f0ad";
 @fa-var-xing: "\f168";
 @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}-opencart:before { content: $fa-var-opencart; }
 .#{$fa-css-prefix}-expeditedssl:before { content: $fa-var-expeditedssl; }
 .#{$fa-css-prefix}-expeditedssl:before { content: $fa-var-expeditedssl; }
 .#{$fa-css-prefix}-battery-4:before,
 .#{$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-full:before { content: $fa-var-battery-full; }
 .#{$fa-css-prefix}-battery-3:before,
 .#{$fa-css-prefix}-battery-3:before,
 .#{$fa-css-prefix}-battery-three-quarters:before { content: $fa-var-battery-three-quarters; }
 .#{$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}-google-plus-official:before { content: $fa-var-google-plus-official; }
 .#{$fa-css-prefix}-fa:before,
 .#{$fa-css-prefix}-fa:before,
 .#{$fa-css-prefix}-font-awesome:before { content: $fa-var-font-awesome; }
 .#{$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