Преглед изворни кода

Merge pull request #3478 from netbox-community/develop

Release v2.6.3
Jeremy Stretch пре 6 година
родитељ
комит
a4d8b92cb1
90 измењених фајлова са 2682 додато и 847 уклоњено
  1. 2 0
      .gitignore
  2. 31 0
      CHANGELOG.md
  3. 35 0
      docs/additional-features/custom-links.md
  4. 213 0
      docs/additional-features/custom-scripts.md
  5. 1 1
      docs/additional-features/reports.md
  6. 8 0
      docs/configuration/optional-settings.md
  7. 1 1
      docs/development/extending-models.md
  8. 2 2
      netbox/circuits/api/views.py
  9. 13 2
      netbox/circuits/filters.py
  10. 8 10
      netbox/circuits/models.py
  11. 5 1
      netbox/circuits/signals.py
  12. 8 14
      netbox/circuits/views.py
  13. 34 68
      netbox/dcim/api/views.py
  14. 12 3
      netbox/dcim/filters.py
  15. 6 6
      netbox/dcim/forms.py
  16. 112 62
      netbox/dcim/models.py
  17. 10 2
      netbox/dcim/signals.py
  18. 1 0
      netbox/dcim/tables.py
  19. 131 1
      netbox/dcim/tests/test_models.py
  20. 70 72
      netbox/dcim/views.py
  21. 2 2
      netbox/extras/api/views.py
  22. 58 7
      netbox/extras/forms.py
  23. 4 0
      netbox/extras/management/commands/nbshell.py
  24. 47 32
      netbox/extras/middleware.py
  25. 23 0
      netbox/extras/migrations/0024_scripts.py
  26. 18 0
      netbox/extras/migrations/0025_objectchange_time_index.py
  27. 23 5
      netbox/extras/models.py
  28. 343 0
      netbox/extras/scripts.py
  29. 12 0
      netbox/extras/signals.py
  30. 37 0
      netbox/extras/templatetags/log_levels.py
  31. 72 0
      netbox/extras/tests/test_changelog.py
  32. 157 0
      netbox/extras/tests/test_scripts.py
  33. 2 2
      netbox/extras/tests/test_tags.py
  34. 5 5
      netbox/extras/tests/test_views.py
  35. 8 3
      netbox/extras/urls.py
  36. 85 18
      netbox/extras/views.py
  37. 9 16
      netbox/ipam/api/views.py
  38. 1 1
      netbox/ipam/filters.py
  39. 6 12
      netbox/ipam/models.py
  40. 34 38
      netbox/ipam/views.py
  41. 1 1
      netbox/netbox/api.py
  42. 2 1
      netbox/netbox/settings.py
  43. 0 1
      netbox/netbox/urls.py
  44. 17 17
      netbox/netbox/views.py
  45. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.css.map
  46. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.min.css.map
  47. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.css.map
  48. 0 4
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css
  49. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css.map
  50. 0 5
      netbox/project-static/bootstrap-3.3.7-dist/js/bootstrap.min.js
  51. 100 100
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css
  52. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css.map
  53. 2 2
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css
  54. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css.map
  55. 255 173
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.css
  56. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.css.map
  57. 4 0
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css
  58. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css.map
  59. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.eot
  60. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.svg
  61. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.ttf
  62. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff
  63. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff2
  64. 300 97
      netbox/project-static/bootstrap-3.4.1-dist/js/bootstrap.js
  65. 5 0
      netbox/project-static/bootstrap-3.4.1-dist/js/bootstrap.min.js
  66. 0 0
      netbox/project-static/bootstrap-3.4.1-dist/js/npm.js
  67. 7 4
      netbox/project-static/css/base.css
  68. 0 0
      netbox/scripts/__init__.py
  69. 66 0
      netbox/scripts/examples.py
  70. 54 0
      netbox/scripts/myscripts.py
  71. 2 4
      netbox/secrets/api/views.py
  72. 3 3
      netbox/secrets/views.py
  73. 1 1
      netbox/templates/500.html
  74. 2 2
      netbox/templates/_base.html
  75. 1 1
      netbox/templates/dcim/device.html
  76. 111 0
      netbox/templates/extras/script.html
  77. 38 0
      netbox/templates/extras/script_list.html
  78. 1 1
      netbox/templates/extras/tag_list.html
  79. 1 0
      netbox/templates/extras/templatetags/log_level.html
  80. 3 0
      netbox/templates/inc/nav_menu.html
  81. 3 5
      netbox/templates/utilities/templatetags/tag.html
  82. 2 4
      netbox/tenancy/api/views.py
  83. 3 3
      netbox/tenancy/views.py
  84. 9 6
      netbox/utilities/api.py
  85. 5 0
      netbox/utilities/exceptions.py
  86. 5 6
      netbox/utilities/models.py
  87. 8 8
      netbox/virtualization/api/views.py
  88. 7 1
      netbox/virtualization/filters.py
  89. 7 3
      netbox/virtualization/forms.py
  90. 8 8
      netbox/virtualization/views.py

+ 2 - 0
.gitignore

@@ -3,6 +3,8 @@
 /netbox/netbox/ldap_config.py
 /netbox/netbox/ldap_config.py
 /netbox/reports/*
 /netbox/reports/*
 !/netbox/reports/__init__.py
 !/netbox/reports/__init__.py
+/netbox/scripts/*
+!/netbox/scripts/__init__.py
 /netbox/static
 /netbox/static
 .idea
 .idea
 /*.sh
 /*.sh

+ 31 - 0
CHANGELOG.md

@@ -1,3 +1,34 @@
+v2.6.3 (2019-09-04)
+
+## New Features
+
+### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415))
+
+Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/additional-features/custom-scripts/) for more detail.
+
+Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release.
+
+## Bug Fixes
+
+* [#3392](https://github.com/netbox-community/netbox/issues/3392) - Add database index for ObjectChange time
+* [#3420](https://github.com/netbox-community/netbox/issues/3420) - Serial number filter for racks, devices, and inventory items is now case-insensitive
+* [#3428](https://github.com/netbox-community/netbox/issues/3428) - Fixed cache invalidation issues ([#3300](https://github.com/netbox-community/netbox/issues/3300), [#3363](https://github.com/netbox-community/netbox/issues/3363), [#3379](https://github.com/netbox-community/netbox/issues/3379), [#3382](https://github.com/netbox-community/netbox/issues/3382)) by switching to `prefetch_related()` instead of `select_related()` and removing use of `update()`
+* [#3421](https://github.com/netbox-community/netbox/issues/3421) - Fix exception when ordering power connections list by PDU
+* [#3424](https://github.com/netbox-community/netbox/issues/3424) - Fix tag coloring for non-linked tags
+* [#3426](https://github.com/netbox-community/netbox/issues/3426) - Improve API error handling for ChoiceFields
+
+## Enhancements
+
+* [#3386](https://github.com/netbox-community/netbox/issues/3386) - Add `mac_address` filter for virtual machines
+* [#3391](https://github.com/netbox-community/netbox/issues/3391) - Update Bootstrap CSS to v3.4.1
+* [#3405](https://github.com/netbox-community/netbox/issues/3405) - Fix population of power port/outlet details on device creation
+* [#3422](https://github.com/netbox-community/netbox/issues/3422) - Prevent navigation menu from overlapping page content
+* [#3430](https://github.com/netbox-community/netbox/issues/3430) - Linkify platform field on device view
+* [#3454](https://github.com/netbox-community/netbox/issues/3454) - Enable filtering circuits by region
+* [#3456](https://github.com/netbox-community/netbox/issues/3456) - Enable bulk editing of tag color
+
+---
+
 v2.6.2 (2019-08-02)
 v2.6.2 (2019-08-02)
 
 
 ## Enhancements
 ## Enhancements

+ 35 - 0
docs/additional-features/custom-links.md

@@ -0,0 +1,35 @@
+# Custom Links
+
+Custom links allow users to place arbitrary hyperlinks within NetBox views. These are helpful for cross-referencing related records in external systems. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
+
+Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
+
+For example, you might define a link like this:
+
+* Text: `View NMS`
+* URL: `https://nms.example.com/nodes/?name={{ obj.name }}`
+
+When viewing a device named Router4, this link would render as:
+
+```
+<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
+```
+
+Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
+
+## Conditional Rendering
+
+Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered.
+
+For example, if you only want to display a link for active devices, you could set the link text to
+
+```
+{% if device.status == 1 %}View NMS{% endif %}
+```
+
+The link will not appear when viewing a device with any status other than "active."
+
+## Link Groups
+
+You can specify a group name to organize links into related sets. Grouped links will render as a dropdown menu beneath a
+single button bearing the name of the group.

+ 213 - 0
docs/additional-features/custom-scripts.md

@@ -0,0 +1,213 @@
+# Custom Scripts
+
+Custom scripting was introduced to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as:
+
+* Automatically populate new devices and cables in preparation for a new site deployment
+* Create a range of new reserved prefixes or IP addresses
+* Fetch data from an external source and import it to NetBox
+
+Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're written from scratch, a custom script can be used to accomplish just about anything.
+
+## Writing Custom Scripts
+
+All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity.
+
+```
+from extras.scripts import Script
+
+class MyScript(Script):
+    ..
+```
+
+Scripts comprise two core components: variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI. The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.)
+
+```
+class MyScript(Script):
+    var1 = StringVar(...)
+    var2 = IntegerVar(...)
+    var3 = ObjectVar(...)
+
+    def run(self, data):
+        ...
+```
+
+The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution.
+
+Defining variables is optional: You may create a script with only a `run()` method if no user input is needed.
+
+Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI.
+
+## Module Attributes
+
+### `name`
+
+You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the filename will be used.
+
+## Script Attributes
+
+Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
+
+### `name`
+
+This is the human-friendly names of your script. If omitted, the class name will be used.
+
+### `description`
+
+A human-friendly description of what your script does.
+
+### `field_order`
+
+A list of field names indicating the order in which the form fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example:
+
+```
+field_order = ['var1', 'var2', 'var3']
+```
+
+## Reading Data from Files
+
+The Script class provides two convenience methods for reading data from files:
+
+* `load_yaml`
+* `load_json`
+
+These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
+
+## Logging
+
+The Script object provides a set of convenient functions for recording messages at different severity levels:
+
+* `log_debug`
+* `log_success`
+* `log_info`
+* `log_warning`
+* `log_failure`
+
+Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
+
+## Variable Reference
+
+### StringVar
+
+Stores a string of characters (i.e. a line of text). Options include:
+
+* `min_length` - Minimum number of characters
+* `max_length` - Maximum number of characters
+* `regex` - A regular expression against which the provided value must match
+
+Note: `min_length` and `max_length` can be set to the same number to effect a fixed-length field.
+
+### TextVar
+
+Arbitrary text of any length. Renders as multi-line text input field.
+
+### IntegerVar
+
+Stored a numeric integer. Options include:
+
+* `min_value:` - Minimum value
+* `max_value` - Maximum value
+
+### BooleanVar
+
+A true/false flag. This field has no options beyond the defaults.
+
+### ObjectVar
+
+A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type.
+
+* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/)
+
+### FileVar
+
+An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
+
+### IPNetworkVar
+
+An IPv4 or IPv6 network with a mask.
+
+### Default Options
+
+All variables support the following default options:
+
+* `label` - The name of the form field
+* `description` - A brief description of the field
+* `default` - The field's default value
+* `required` - Indicates whether the field is mandatory (default: true)
+
+## Example
+
+Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
+
+* The name of the new site
+* The device model (a filtered list of defined device types)
+* The number of access switches to create
+
+These variables are presented as a web form to be completed by the user. Once submitted, the script's `run()` method is called to create the appropriate objects.
+
+```
+from django.utils.text import slugify
+
+from dcim.constants import *
+from dcim.models import Device, DeviceRole, DeviceType, Site
+from extras.scripts import *
+
+
+class NewBranchScript(Script):
+
+    class Meta:
+        name = "New Branch"
+        description = "Provision a new branch site"
+        fields = ['site_name', 'switch_count', 'switch_model']
+
+    site_name = StringVar(
+        description="Name of the new site"
+    )
+    switch_count = IntegerVar(
+        description="Number of access switches to create"
+    )
+    switch_model = ObjectVar(
+        description="Access switch model",
+        queryset = DeviceType.objects.filter(
+            manufacturer__name='Cisco',
+            model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
+        )
+    )
+
+    def run(self, data):
+
+        # Create the new site
+        site = Site(
+            name=data['site_name'],
+            slug=slugify(data['site_name']),
+            status=SITE_STATUS_PLANNED
+        )
+        site.save()
+        self.log_success("Created new site: {}".format(site))
+
+        # Create access switches
+        switch_role = DeviceRole.objects.get(name='Access Switch')
+        for i in range(1, data['switch_count'] + 1):
+            switch = Device(
+                device_type=data['switch_model'],
+                name='{}-switch{}'.format(site.slug, i),
+                site=site,
+                status=DEVICE_STATUS_PLANNED,
+                device_role=switch_role
+            )
+            switch.save()
+            self.log_success("Created new switch: {}".format(switch))
+
+        # Generate a CSV table of new devices
+        output = [
+            'name,make,model'
+        ]
+        for switch in Device.objects.filter(site=site):
+            attrs = [
+                switch.name,
+                switch.device_type.manufacturer.name,
+                switch.device_type.model
+            ]
+            output.append(','.join(attrs))
+
+        return '\n'.join(output)
+```

+ 1 - 1
docs/additional-features/reports.md

@@ -43,7 +43,7 @@ class DeviceConnectionsReport(Report):
     def test_console_connection(self):
     def test_console_connection(self):
 
 
         # Check that every console port for every active device has a connection defined.
         # Check that every console port for every active device has a connection defined.
-        for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
+        for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
             if console_port.connected_endpoint is None:
             if console_port.connected_endpoint is None:
                 self.log_failure(
                 self.log_failure(
                     console_port.device,
                     console_port.device,

+ 8 - 0
docs/configuration/optional-settings.md

@@ -277,6 +277,14 @@ The file path to the location where custom reports will be kept. By default, thi
 
 
 ---
 ---
 
 
+## SCRIPTS_ROOT
+
+Default: $BASE_DIR/netbox/scripts/
+
+The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
+
+---
+
 ## SESSION_FILE_PATH
 ## SESSION_FILE_PATH
 
 
 Default: None
 Default: None

+ 1 - 1
docs/development/extending-models.md

@@ -38,7 +38,7 @@ Add the name of the new field to `csv_headers` and included a CSV-friendly repre
 
 
 ### 4. Update relevant querysets
 ### 4. Update relevant querysets
 
 
-If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `select_related()` or `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
+If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
 
 
 ### 5. Update API serializer
 ### 5. Update API serializer
 
 

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

@@ -62,7 +62,7 @@ class CircuitTypeViewSet(ModelViewSet):
 #
 #
 
 
 class CircuitViewSet(CustomFieldModelViewSet):
 class CircuitViewSet(CustomFieldModelViewSet):
-    queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags')
+    queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags')
     serializer_class = serializers.CircuitSerializer
     serializer_class = serializers.CircuitSerializer
     filterset_class = filters.CircuitFilter
     filterset_class = filters.CircuitFilter
 
 
@@ -72,7 +72,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class CircuitTerminationViewSet(ModelViewSet):
 class CircuitTerminationViewSet(ModelViewSet):
-    queryset = CircuitTermination.objects.select_related(
+    queryset = CircuitTermination.objects.prefetch_related(
         'circuit', 'site', 'connected_endpoint__device', 'cable'
         'circuit', 'site', 'connected_endpoint__device', 'cable'
     )
     )
     serializer_class = serializers.CircuitTerminationSerializer
     serializer_class = serializers.CircuitTerminationSerializer

+ 13 - 2
netbox/circuits/filters.py

@@ -1,10 +1,10 @@
 import django_filters
 import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
-from dcim.models import Site
+from dcim.models import Region, Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
-from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from .constants import CIRCUIT_STATUS_CHOICES
 from .constants import CIRCUIT_STATUS_CHOICES
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 
 
@@ -98,6 +98,17 @@ class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='terminations__site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='terminations__site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:

+ 8 - 10
netbox/circuits/models.py

@@ -270,23 +270,21 @@ class CircuitTermination(CableTermination):
     def __str__(self):
     def __str__(self):
         return 'Side {}'.format(self.get_term_side_display())
         return 'Side {}'.format(self.get_term_side_display())
 
 
-    def log_change(self, user, request_id, action):
-        """
-        Reference the parent circuit when recording the change.
-        """
+    def to_objectchange(self, action):
+        # Annotate the parent Circuit
         try:
         try:
             related_object = self.circuit
             related_object = self.circuit
         except Circuit.DoesNotExist:
         except Circuit.DoesNotExist:
             # Parent circuit has been deleted
             # Parent circuit has been deleted
             related_object = None
             related_object = None
-        ObjectChange(
-            user=user,
-            request_id=request_id,
+
+        return ObjectChange(
             changed_object=self,
             changed_object=self,
-            related_object=related_object,
+            object_repr=str(self),
             action=action,
             action=action,
+            related_object=related_object,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
-        ).save()
+        )
 
 
     @property
     @property
     def parent(self):
     def parent(self):
@@ -295,6 +293,6 @@ class CircuitTermination(CableTermination):
     def get_peer_termination(self):
     def get_peer_termination(self):
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         try:
         try:
-            return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side)
+            return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side)
         except CircuitTermination.DoesNotExist:
         except CircuitTermination.DoesNotExist:
             return None
             return None

+ 5 - 1
netbox/circuits/signals.py

@@ -10,4 +10,8 @@ def update_circuit(instance, **kwargs):
     """
     """
     When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
     When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
     """
     """
-    Circuit.objects.filter(pk=instance.circuit_id).update(last_updated=timezone.now())
+    circuits = Circuit.objects.filter(pk=instance.circuit_id)
+    time = timezone.now()
+    for circuit in circuits:
+        circuit.last_updated = time
+        circuit.save()

+ 8 - 14
netbox/circuits/views.py

@@ -35,11 +35,7 @@ class ProviderView(PermissionRequiredMixin, View):
     def get(self, request, slug):
     def get(self, request, slug):
 
 
         provider = get_object_or_404(Provider, slug=slug)
         provider = get_object_or_404(Provider, slug=slug)
-        circuits = Circuit.objects.filter(provider=provider).select_related(
-            'type', 'tenant'
-        ).prefetch_related(
-            'terminations__site'
-        )
+        circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site')
         show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
         show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
 
 
         return render(request, 'circuits/provider.html', {
         return render(request, 'circuits/provider.html', {
@@ -134,10 +130,8 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class CircuitListView(PermissionRequiredMixin, ObjectListView):
 class CircuitListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'circuits.view_circuit'
     permission_required = 'circuits.view_circuit'
     _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
     _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
-    queryset = Circuit.objects.select_related(
-        'provider', 'type', 'tenant'
-    ).prefetch_related(
-        'terminations__site'
+    queryset = Circuit.objects.prefetch_related(
+        'provider', 'type', 'tenant', 'terminations__site'
     ).annotate(
     ).annotate(
         a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]),
         a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]),
         z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]),
         z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]),
@@ -153,13 +147,13 @@ class CircuitView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
-        termination_a = CircuitTermination.objects.select_related(
+        circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk)
+        termination_a = CircuitTermination.objects.prefetch_related(
             'site__region', 'connected_endpoint__device'
             'site__region', 'connected_endpoint__device'
         ).filter(
         ).filter(
             circuit=circuit, term_side=TERM_SIDE_A
             circuit=circuit, term_side=TERM_SIDE_A
         ).first()
         ).first()
-        termination_z = CircuitTermination.objects.select_related(
+        termination_z = CircuitTermination.objects.prefetch_related(
             'site__region', 'connected_endpoint__device'
             'site__region', 'connected_endpoint__device'
         ).filter(
         ).filter(
             circuit=circuit, term_side=TERM_SIDE_Z
             circuit=circuit, term_side=TERM_SIDE_Z
@@ -199,7 +193,7 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
 class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_circuit'
     permission_required = 'circuits.change_circuit'
-    queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
+    queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
     table = tables.CircuitTable
     table = tables.CircuitTable
     form = forms.CircuitBulkEditForm
     form = forms.CircuitBulkEditForm
@@ -208,7 +202,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuit'
     permission_required = 'circuits.delete_circuit'
-    queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
+    queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
     table = tables.CircuitTable
     table = tables.CircuitTable
     default_return_url = 'circuits:circuit_list'
     default_return_url = 'circuits:circuit_list'

+ 34 - 68
netbox/dcim/api/views.py

@@ -109,10 +109,8 @@ class RegionViewSet(ModelViewSet):
 #
 #
 
 
 class SiteViewSet(CustomFieldModelViewSet):
 class SiteViewSet(CustomFieldModelViewSet):
-    queryset = Site.objects.select_related(
-        'region', 'tenant'
-    ).prefetch_related(
-        'tags'
+    queryset = Site.objects.prefetch_related(
+        'region', 'tenant', 'tags'
     ).annotate(
     ).annotate(
         device_count=get_subquery(Device, 'site'),
         device_count=get_subquery(Device, 'site'),
         rack_count=get_subquery(Rack, 'site'),
         rack_count=get_subquery(Rack, 'site'),
@@ -140,7 +138,7 @@ class SiteViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class RackGroupViewSet(ModelViewSet):
 class RackGroupViewSet(ModelViewSet):
-    queryset = RackGroup.objects.select_related('site').annotate(
+    queryset = RackGroup.objects.prefetch_related('site').annotate(
         rack_count=Count('racks')
         rack_count=Count('racks')
     )
     )
     serializer_class = serializers.RackGroupSerializer
     serializer_class = serializers.RackGroupSerializer
@@ -164,10 +162,8 @@ class RackRoleViewSet(ModelViewSet):
 #
 #
 
 
 class RackViewSet(CustomFieldModelViewSet):
 class RackViewSet(CustomFieldModelViewSet):
-    queryset = Rack.objects.select_related(
-        'site', 'group__site', 'role', 'tenant'
-    ).prefetch_related(
-        'tags'
+    queryset = Rack.objects.prefetch_related(
+        'site', 'group__site', 'role', 'tenant', 'tags'
     ).annotate(
     ).annotate(
         device_count=get_subquery(Device, 'rack'),
         device_count=get_subquery(Device, 'rack'),
         powerfeed_count=get_subquery(PowerFeed, 'rack')
         powerfeed_count=get_subquery(PowerFeed, 'rack')
@@ -206,7 +202,7 @@ class RackViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class RackReservationViewSet(ModelViewSet):
 class RackReservationViewSet(ModelViewSet):
-    queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
+    queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
     serializer_class = serializers.RackReservationSerializer
     serializer_class = serializers.RackReservationSerializer
     filterset_class = filters.RackReservationFilter
     filterset_class = filters.RackReservationFilter
 
 
@@ -234,7 +230,7 @@ class ManufacturerViewSet(ModelViewSet):
 #
 #
 
 
 class DeviceTypeViewSet(CustomFieldModelViewSet):
 class DeviceTypeViewSet(CustomFieldModelViewSet):
-    queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags').annotate(
+    queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate(
         device_count=Count('instances')
         device_count=Count('instances')
     )
     )
     serializer_class = serializers.DeviceTypeSerializer
     serializer_class = serializers.DeviceTypeSerializer
@@ -246,49 +242,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class ConsolePortTemplateViewSet(ModelViewSet):
 class ConsolePortTemplateViewSet(ModelViewSet):
-    queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
+    queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsolePortTemplateSerializer
     serializer_class = serializers.ConsolePortTemplateSerializer
     filterset_class = filters.ConsolePortTemplateFilter
     filterset_class = filters.ConsolePortTemplateFilter
 
 
 
 
 class ConsoleServerPortTemplateViewSet(ModelViewSet):
 class ConsoleServerPortTemplateViewSet(ModelViewSet):
-    queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
+    queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
     filterset_class = filters.ConsoleServerPortTemplateFilter
     filterset_class = filters.ConsoleServerPortTemplateFilter
 
 
 
 
 class PowerPortTemplateViewSet(ModelViewSet):
 class PowerPortTemplateViewSet(ModelViewSet):
-    queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
+    queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerPortTemplateSerializer
     serializer_class = serializers.PowerPortTemplateSerializer
     filterset_class = filters.PowerPortTemplateFilter
     filterset_class = filters.PowerPortTemplateFilter
 
 
 
 
 class PowerOutletTemplateViewSet(ModelViewSet):
 class PowerOutletTemplateViewSet(ModelViewSet):
-    queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
+    queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerOutletTemplateSerializer
     serializer_class = serializers.PowerOutletTemplateSerializer
     filterset_class = filters.PowerOutletTemplateFilter
     filterset_class = filters.PowerOutletTemplateFilter
 
 
 
 
 class InterfaceTemplateViewSet(ModelViewSet):
 class InterfaceTemplateViewSet(ModelViewSet):
-    queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
+    queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.InterfaceTemplateSerializer
     serializer_class = serializers.InterfaceTemplateSerializer
     filterset_class = filters.InterfaceTemplateFilter
     filterset_class = filters.InterfaceTemplateFilter
 
 
 
 
 class FrontPortTemplateViewSet(ModelViewSet):
 class FrontPortTemplateViewSet(ModelViewSet):
-    queryset = FrontPortTemplate.objects.select_related('device_type__manufacturer')
+    queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.FrontPortTemplateSerializer
     serializer_class = serializers.FrontPortTemplateSerializer
     filterset_class = filters.FrontPortTemplateFilter
     filterset_class = filters.FrontPortTemplateFilter
 
 
 
 
 class RearPortTemplateViewSet(ModelViewSet):
 class RearPortTemplateViewSet(ModelViewSet):
-    queryset = RearPortTemplate.objects.select_related('device_type__manufacturer')
+    queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.RearPortTemplateSerializer
     serializer_class = serializers.RearPortTemplateSerializer
     filterset_class = filters.RearPortTemplateFilter
     filterset_class = filters.RearPortTemplateFilter
 
 
 
 
 class DeviceBayTemplateViewSet(ModelViewSet):
 class DeviceBayTemplateViewSet(ModelViewSet):
-    queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
+    queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
     serializer_class = serializers.DeviceBayTemplateSerializer
     filterset_class = filters.DeviceBayTemplateFilter
     filterset_class = filters.DeviceBayTemplateFilter
 
 
@@ -324,11 +320,9 @@ class PlatformViewSet(ModelViewSet):
 #
 #
 
 
 class DeviceViewSet(CustomFieldModelViewSet):
 class DeviceViewSet(CustomFieldModelViewSet):
-    queryset = Device.objects.select_related(
+    queryset = Device.objects.prefetch_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
-        'virtual_chassis__master',
-    ).prefetch_related(
-        'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
+        'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
     )
     )
     filterset_class = filters.DeviceFilter
     filterset_class = filters.DeviceFilter
 
 
@@ -429,52 +423,36 @@ class DeviceViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class ConsolePortViewSet(CableTraceMixin, ModelViewSet):
 class ConsolePortViewSet(CableTraceMixin, ModelViewSet):
-    queryset = ConsolePort.objects.select_related(
-        'device', 'connected_endpoint__device', 'cable'
-    ).prefetch_related(
-        'tags'
-    )
+    queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
     serializer_class = serializers.ConsolePortSerializer
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filters.ConsolePortFilter
     filterset_class = filters.ConsolePortFilter
 
 
 
 
 class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
 class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
-    queryset = ConsoleServerPort.objects.select_related(
-        'device', 'connected_endpoint__device', 'cable'
-    ).prefetch_related(
-        'tags'
-    )
+    queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
     serializer_class = serializers.ConsoleServerPortSerializer
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filters.ConsoleServerPortFilter
     filterset_class = filters.ConsoleServerPortFilter
 
 
 
 
 class PowerPortViewSet(CableTraceMixin, ModelViewSet):
 class PowerPortViewSet(CableTraceMixin, ModelViewSet):
-    queryset = PowerPort.objects.select_related(
-        'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable'
-    ).prefetch_related(
-        'tags'
+    queryset = PowerPort.objects.prefetch_related(
+        'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags'
     )
     )
     serializer_class = serializers.PowerPortSerializer
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filters.PowerPortFilter
     filterset_class = filters.PowerPortFilter
 
 
 
 
 class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
 class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
-    queryset = PowerOutlet.objects.select_related(
-        'device', 'connected_endpoint__device', 'cable'
-    ).prefetch_related(
-        'tags'
-    )
+    queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
     serializer_class = serializers.PowerOutletSerializer
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filters.PowerOutletFilter
     filterset_class = filters.PowerOutletFilter
 
 
 
 
 class InterfaceViewSet(CableTraceMixin, ModelViewSet):
 class InterfaceViewSet(CableTraceMixin, ModelViewSet):
-    queryset = Interface.objects.filter(
+    queryset = Interface.objects.prefetch_related(
+        'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags'
+    ).filter(
         device__isnull=False
         device__isnull=False
-    ).select_related(
-        'device', '_connected_interface', '_connected_circuittermination', 'cable'
-    ).prefetch_related(
-        'ip_addresses', 'tags'
     )
     )
     serializer_class = serializers.InterfaceSerializer
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filters.InterfaceFilter
     filterset_class = filters.InterfaceFilter
@@ -491,33 +469,25 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
 
 
 
 
 class FrontPortViewSet(ModelViewSet):
 class FrontPortViewSet(ModelViewSet):
-    queryset = FrontPort.objects.select_related(
-        'device__device_type__manufacturer', 'rear_port', 'cable'
-    ).prefetch_related(
-        'tags'
-    )
+    queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
     serializer_class = serializers.FrontPortSerializer
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filters.FrontPortFilter
     filterset_class = filters.FrontPortFilter
 
 
 
 
 class RearPortViewSet(ModelViewSet):
 class RearPortViewSet(ModelViewSet):
-    queryset = RearPort.objects.select_related(
-        'device__device_type__manufacturer', 'cable'
-    ).prefetch_related(
-        'tags'
-    )
+    queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
     serializer_class = serializers.RearPortSerializer
     serializer_class = serializers.RearPortSerializer
     filterset_class = filters.RearPortFilter
     filterset_class = filters.RearPortFilter
 
 
 
 
 class DeviceBayViewSet(ModelViewSet):
 class DeviceBayViewSet(ModelViewSet):
-    queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags')
+    queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
     serializer_class = serializers.DeviceBaySerializer
     serializer_class = serializers.DeviceBaySerializer
     filterset_class = filters.DeviceBayFilter
     filterset_class = filters.DeviceBayFilter
 
 
 
 
 class InventoryItemViewSet(ModelViewSet):
 class InventoryItemViewSet(ModelViewSet):
-    queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags')
+    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
     serializer_class = serializers.InventoryItemSerializer
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filters.InventoryItemFilter
     filterset_class = filters.InventoryItemFilter
 
 
@@ -527,7 +497,7 @@ class InventoryItemViewSet(ModelViewSet):
 #
 #
 
 
 class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
 class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
-    queryset = ConsolePort.objects.select_related(
+    queryset = ConsolePort.objects.prefetch_related(
         'device', 'connected_endpoint__device'
         'device', 'connected_endpoint__device'
     ).filter(
     ).filter(
         connected_endpoint__isnull=False
         connected_endpoint__isnull=False
@@ -537,7 +507,7 @@ class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
 
 
 
 
 class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
-    queryset = PowerPort.objects.select_related(
+    queryset = PowerPort.objects.prefetch_related(
         'device', 'connected_endpoint__device'
         'device', 'connected_endpoint__device'
     ).filter(
     ).filter(
         _connected_poweroutlet__isnull=False
         _connected_poweroutlet__isnull=False
@@ -547,7 +517,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 
 
 
 
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
-    queryset = Interface.objects.select_related(
+    queryset = Interface.objects.prefetch_related(
         'device', '_connected_interface__device'
         'device', '_connected_interface__device'
     ).filter(
     ).filter(
         # Avoid duplicate connections by only selecting the lower PK in a connected pair
         # Avoid duplicate connections by only selecting the lower PK in a connected pair
@@ -587,7 +557,7 @@ class VirtualChassisViewSet(ModelViewSet):
 #
 #
 
 
 class PowerPanelViewSet(ModelViewSet):
 class PowerPanelViewSet(ModelViewSet):
-    queryset = PowerPanel.objects.select_related(
+    queryset = PowerPanel.objects.prefetch_related(
         'site', 'rack_group'
         'site', 'rack_group'
     ).annotate(
     ).annotate(
         powerfeed_count=Count('powerfeeds')
         powerfeed_count=Count('powerfeeds')
@@ -601,11 +571,7 @@ class PowerPanelViewSet(ModelViewSet):
 #
 #
 
 
 class PowerFeedViewSet(CustomFieldModelViewSet):
 class PowerFeedViewSet(CustomFieldModelViewSet):
-    queryset = PowerFeed.objects.select_related(
-        'power_panel', 'rack'
-    ).prefetch_related(
-        'tags'
-    )
+    queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags')
     serializer_class = serializers.PowerFeedSerializer
     serializer_class = serializers.PowerFeedSerializer
     filterset_class = filters.PowerFeedFilter
     filterset_class = filters.PowerFeedFilter
 
 

+ 12 - 3
netbox/dcim/filters.py

@@ -160,12 +160,15 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
+    serial = django_filters.CharFilter(
+        lookup_expr='iexact'
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
-            'id', 'name', 'facility_id', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
+            'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
             'outer_width', 'outer_depth', 'outer_unit',
             'outer_width', 'outer_depth', 'outer_unit',
         ]
         ]
 
 
@@ -519,6 +522,9 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
         field_name='interfaces__mac_address',
         field_name='interfaces__mac_address',
         label='MAC address',
         label='MAC address',
     )
     )
+    serial = django_filters.CharFilter(
+        lookup_expr='iexact'
+    )
     has_primary_ip = django_filters.BooleanFilter(
     has_primary_ip = django_filters.BooleanFilter(
         method='_has_primary_ip',
         method='_has_primary_ip',
         label='Has a primary IP',
         label='Has a primary IP',
@@ -560,7 +566,7 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
-        fields = ['id', 'name', 'serial', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority']
+        fields = ['id', 'name', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -847,10 +853,13 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (slug)',
         label='Manufacturer (slug)',
     )
     )
+    serial = django_filters.CharFilter(
+        lookup_expr='iexact'
+    )
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['id', 'name', 'part_id', 'serial', 'asset_tag', 'discovered']
+        fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 6 - 6
netbox/dcim/forms.py

@@ -632,7 +632,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     )
     )
     group_id = ChainedModelChoiceField(
     group_id = ChainedModelChoiceField(
         label='Rack group',
         label='Rack group',
-        queryset=RackGroup.objects.select_related('site'),
+        queryset=RackGroup.objects.prefetch_related('site'),
         chains=(
         chains=(
             ('site', 'site'),
             ('site', 'site'),
         ),
         ),
@@ -745,7 +745,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
         )
         )
     )
     )
     group_id = FilterChoiceField(
     group_id = FilterChoiceField(
-        queryset=RackGroup.objects.select_related('site'),
+        queryset=RackGroup.objects.prefetch_related('site'),
         label='Rack group',
         label='Rack group',
         null_label='-- None --',
         null_label='-- None --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
@@ -1391,14 +1391,14 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
                 interface_ids = self.instance.vc_interfaces.values('pk')
                 interface_ids = self.instance.vc_interfaces.values('pk')
 
 
                 # Collect interface IPs
                 # Collect interface IPs
-                interface_ips = IPAddress.objects.select_related('interface').filter(
+                interface_ips = IPAddress.objects.prefetch_related('interface').filter(
                     family=family, interface_id__in=interface_ids
                     family=family, interface_id__in=interface_ids
                 )
                 )
                 if interface_ips:
                 if interface_ips:
                     ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
                     ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
                     ip_choices.append(('Interface IPs', ip_list))
                     ip_choices.append(('Interface IPs', ip_list))
                 # Collect NAT IPs
                 # Collect NAT IPs
-                nat_ips = IPAddress.objects.select_related('nat_inside').filter(
+                nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                     family=family, nat_inside__interface__in=interface_ids
                     family=family, nat_inside__interface__in=interface_ids
                 )
                 )
                 if nat_ips:
                 if nat_ips:
@@ -1710,7 +1710,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         )
         )
     )
     )
     rack_group_id = FilterChoiceField(
     rack_group_id = FilterChoiceField(
-        queryset=RackGroup.objects.select_related(
+        queryset=RackGroup.objects.prefetch_related(
             'site'
             'site'
         ),
         ),
         label='Rack group',
         label='Rack group',
@@ -1749,7 +1749,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         )
         )
     )
     )
     device_type_id = FilterChoiceField(
     device_type_id = FilterChoiceField(
-        queryset=DeviceType.objects.select_related(
+        queryset=DeviceType.objects.prefetch_related(
             'manufacturer'
             'manufacturer'
         ),
         ),
         label='Model',
         label='Model',

+ 112 - 62
netbox/dcim/models.py

@@ -31,18 +31,20 @@ class ComponentTemplateModel(models.Model):
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
-    def log_change(self, user, request_id, action):
+    def instantiate(self, device):
         """
         """
-        Log an ObjectChange including the parent DeviceType.
+        Instantiate a new component on the specified Device.
         """
         """
-        ObjectChange(
-            user=user,
-            request_id=request_id,
+        raise NotImplementedError()
+
+    def to_objectchange(self, action):
+        return ObjectChange(
             changed_object=self,
             changed_object=self,
-            related_object=self.device_type,
+            object_repr=str(self),
             action=action,
             action=action,
+            related_object=self.device_type,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
-        ).save()
+        )
 
 
 
 
 class ComponentModel(models.Model):
 class ComponentModel(models.Model):
@@ -54,23 +56,21 @@ class ComponentModel(models.Model):
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
-    def log_change(self, user, request_id, action):
-        """
-        Log an ObjectChange including the parent Device/VM.
-        """
+    def to_objectchange(self, action):
+        # Annotate the parent Device/VM
         try:
         try:
             parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
             parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             # The parent device/VM has already been deleted
             # The parent device/VM has already been deleted
             parent = None
             parent = None
-        ObjectChange(
-            user=user,
-            request_id=request_id,
+
+        return ObjectChange(
             changed_object=self,
             changed_object=self,
-            related_object=parent,
+            object_repr=str(self),
             action=action,
             action=action,
+            related_object=parent,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
-        ).save()
+        )
 
 
     @property
     @property
     def parent(self):
     def parent(self):
@@ -601,7 +601,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
 
 
         # Update racked devices if the assigned Site has been changed.
         # Update racked devices if the assigned Site has been changed.
         if _site_id is not None and self.site_id != _site_id:
         if _site_id is not None and self.site_id != _site_id:
-            Device.objects.filter(rack=self).update(site_id=self.site.pk)
+            devices = Device.objects.filter(rack=self)
+            for device in devices:
+                device.site = self.site
+                device.save()
 
 
     def to_csv(self):
     def to_csv(self):
         return (
         return (
@@ -658,7 +661,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
 
 
         # Add devices to rack units list
         # Add devices to rack units list
         if self.pk:
         if self.pk:
-            for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
+            for device in Device.objects.prefetch_related('device_type__manufacturer', 'device_role')\
                     .annotate(devicebay_count=Count('device_bays'))\
                     .annotate(devicebay_count=Count('device_bays'))\
                     .exclude(pk=exclude)\
                     .exclude(pk=exclude)\
                     .filter(rack=self, position__gt=0)\
                     .filter(rack=self, position__gt=0)\
@@ -691,7 +694,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         """
         """
 
 
         # Gather all devices which consume U space within the rack
         # Gather all devices which consume U space within the rack
-        devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
+        devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
 
 
         # Initialize the rack unit skeleton
         # Initialize the rack unit skeleton
         units = list(range(1, self.u_height + 1))
         units = list(range(1, self.u_height + 1))
@@ -1010,6 +1013,12 @@ class ConsolePortTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def instantiate(self, device):
+        return ConsolePort(
+            device=device,
+            name=self.name
+        )
+
 
 
 class ConsoleServerPortTemplate(ComponentTemplateModel):
 class ConsoleServerPortTemplate(ComponentTemplateModel):
     """
     """
@@ -1033,6 +1042,12 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def instantiate(self, device):
+        return ConsoleServerPort(
+            device=device,
+            name=self.name
+        )
+
 
 
 class PowerPortTemplate(ComponentTemplateModel):
 class PowerPortTemplate(ComponentTemplateModel):
     """
     """
@@ -1068,6 +1083,14 @@ class PowerPortTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def instantiate(self, device):
+        return PowerPort(
+            device=device,
+            name=self.name,
+            maximum_draw=self.maximum_draw,
+            allocated_draw=self.allocated_draw
+        )
+
 
 
 class PowerOutletTemplate(ComponentTemplateModel):
 class PowerOutletTemplate(ComponentTemplateModel):
     """
     """
@@ -1112,6 +1135,18 @@ class PowerOutletTemplate(ComponentTemplateModel):
                 "Parent power port ({}) must belong to the same device type".format(self.power_port)
                 "Parent power port ({}) must belong to the same device type".format(self.power_port)
             )
             )
 
 
+    def instantiate(self, device):
+        if self.power_port:
+            power_port = PowerPort.objects.get(device=device, name=self.power_port.name)
+        else:
+            power_port = None
+        return PowerOutlet(
+            device=device,
+            name=self.name,
+            power_port=power_port,
+            feed_leg=self.feed_leg
+        )
+
 
 
 class InterfaceTemplate(ComponentTemplateModel):
 class InterfaceTemplate(ComponentTemplateModel):
     """
     """
@@ -1159,6 +1194,14 @@ class InterfaceTemplate(ComponentTemplateModel):
         """
         """
         self.type = value
         self.type = value
 
 
+    def instantiate(self, device):
+        return Interface(
+            device=device,
+            name=self.name,
+            type=self.type,
+            mgmt_only=self.mgmt_only
+        )
+
 
 
 class FrontPortTemplate(ComponentTemplateModel):
 class FrontPortTemplate(ComponentTemplateModel):
     """
     """
@@ -1213,6 +1256,19 @@ class FrontPortTemplate(ComponentTemplateModel):
                 )
                 )
             )
             )
 
 
+    def instantiate(self, device):
+        if self.rear_port:
+            rear_port = RearPort.objects.get(device=device, name=self.rear_port.name)
+        else:
+            rear_port = None
+        return FrontPort(
+            device=device,
+            name=self.name,
+            type=self.type,
+            rear_port=rear_port,
+            rear_port_position=self.rear_port_position
+        )
+
 
 
 class RearPortTemplate(ComponentTemplateModel):
 class RearPortTemplate(ComponentTemplateModel):
     """
     """
@@ -1243,6 +1299,14 @@ class RearPortTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def instantiate(self, device):
+        return RearPort(
+            device=device,
+            name=self.name,
+            type=self.type,
+            positions=self.positions
+        )
+
 
 
 class DeviceBayTemplate(ComponentTemplateModel):
 class DeviceBayTemplate(ComponentTemplateModel):
     """
     """
@@ -1266,6 +1330,12 @@ class DeviceBayTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def instantiate(self, device):
+        return DeviceBay(
+            device=device,
+            name=self.name
+        )
+
 
 
 #
 #
 # Devices
 # Devices
@@ -1640,49 +1710,36 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         # If this is a new Device, instantiate all of the related components per the DeviceType definition
         # If this is a new Device, instantiate all of the related components per the DeviceType definition
         if is_new:
         if is_new:
             ConsolePort.objects.bulk_create(
             ConsolePort.objects.bulk_create(
-                [ConsolePort(device=self, name=template.name) for template in
-                 self.device_type.consoleport_templates.all()]
+                [x.instantiate(self) for x in self.device_type.consoleport_templates.all()]
             )
             )
             ConsoleServerPort.objects.bulk_create(
             ConsoleServerPort.objects.bulk_create(
-                [ConsoleServerPort(device=self, name=template.name) for template in
-                 self.device_type.consoleserverport_templates.all()]
+                [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
             )
             )
             PowerPort.objects.bulk_create(
             PowerPort.objects.bulk_create(
-                [PowerPort(device=self, name=template.name) for template in
-                 self.device_type.powerport_templates.all()]
+                [x.instantiate(self) for x in self.device_type.powerport_templates.all()]
             )
             )
             PowerOutlet.objects.bulk_create(
             PowerOutlet.objects.bulk_create(
-                [PowerOutlet(device=self, name=template.name) for template in
-                 self.device_type.poweroutlet_templates.all()]
+                [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
             )
             )
             Interface.objects.bulk_create(
             Interface.objects.bulk_create(
-                [Interface(device=self, name=template.name, type=template.type,
-                           mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
+                [x.instantiate(self) for x in self.device_type.interface_templates.all()]
+            )
+            RearPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.rearport_templates.all()]
+            )
+            FrontPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.frontport_templates.all()]
             )
             )
-            RearPort.objects.bulk_create([
-                RearPort(
-                    device=self,
-                    name=template.name,
-                    type=template.type,
-                    positions=template.positions
-                ) for template in self.device_type.rearport_templates.all()
-            ])
-            FrontPort.objects.bulk_create([
-                FrontPort(
-                    device=self,
-                    name=template.name,
-                    type=template.type,
-                    rear_port=RearPort.objects.get(device=self, name=template.rear_port.name),
-                    rear_port_position=template.rear_port_position,
-                ) for template in self.device_type.frontport_templates.all()
-            ])
             DeviceBay.objects.bulk_create(
             DeviceBay.objects.bulk_create(
-                [DeviceBay(device=self, name=template.name) for template in
-                 self.device_type.device_bay_templates.all()]
+                [x.instantiate(self) for x in self.device_type.device_bay_templates.all()]
             )
             )
 
 
         # Update Site and Rack assignment for any child Devices
         # Update Site and Rack assignment for any child Devices
-        Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
+        devices = Device.objects.filter(parent_bay__device=self)
+        for device in devices:
+            device.site = self.site
+            device.rack = self.rack
+            device.save()
 
 
     def to_csv(self):
     def to_csv(self):
         return (
         return (
@@ -2264,27 +2321,20 @@ class Interface(CableTermination, ComponentModel):
 
 
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
-    def log_change(self, user, request_id, action):
-        """
-        Include the connected Interface (if any).
-        """
-
-        # It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve
-        # the component parent will raise DoesNotExist. For more discussion, see
-        # https://github.com/netbox-community/netbox/issues/2323
+    def to_objectchange(self, action):
+        # Annotate the parent Device/VM
         try:
         try:
             parent_obj = self.device or self.virtual_machine
             parent_obj = self.device or self.virtual_machine
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             parent_obj = None
             parent_obj = None
 
 
-        ObjectChange(
-            user=user,
-            request_id=request_id,
+        return ObjectChange(
             changed_object=self,
             changed_object=self,
-            related_object=parent_obj,
+            object_repr=str(self),
             action=action,
             action=action,
+            related_object=parent_obj,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
-        ).save()
+        )
 
 
     # TODO: Remove in v2.7
     # TODO: Remove in v2.7
     @property
     @property

+ 10 - 2
netbox/dcim/signals.py

@@ -10,7 +10,11 @@ def assign_virtualchassis_master(instance, created, **kwargs):
     When a VirtualChassis is created, automatically assign its master device to the VC.
     When a VirtualChassis is created, automatically assign its master device to the VC.
     """
     """
     if created:
     if created:
-        Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=None)
+        devices = Device.objects.filter(pk=instance.master.pk)
+        for device in devices:
+            device.virtual_chassis = instance
+            device.vc_position = None
+            device.save()
 
 
 
 
 @receiver(pre_delete, sender=VirtualChassis)
 @receiver(pre_delete, sender=VirtualChassis)
@@ -18,7 +22,11 @@ def clear_virtualchassis_members(instance, **kwargs):
     """
     """
     When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
     When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
     """
     """
-    Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None)
+    devices = Device.objects.filter(virtual_chassis=instance.pk)
+    for device in devices:
+        device.vc_position = None
+        device.vc_priority = None
+        device.save()
 
 
 
 
 @receiver(post_save, sender=Cable)
 @receiver(post_save, sender=Cable)

+ 1 - 0
netbox/dcim/tables.py

@@ -729,6 +729,7 @@ class PowerConnectionTable(BaseTable):
         viewname='dcim:device',
         viewname='dcim:device',
         accessor=Accessor('connected_endpoint.device'),
         accessor=Accessor('connected_endpoint.device'),
         args=[Accessor('connected_endpoint.device.pk')],
         args=[Accessor('connected_endpoint.device.pk')],
+        order_by='_connected_poweroutlet__device',
         verbose_name='PDU'
         verbose_name='PDU'
     )
     )
     outlet = tables.Column(
     outlet = tables.Column(

+ 131 - 1
netbox/dcim/tests/test_models.py

@@ -1,6 +1,5 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
-from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 
 
 
 
@@ -152,6 +151,137 @@ class RackTestCase(TestCase):
         self.assertTrue(pdu)
         self.assertTrue(pdu)
 
 
 
 
+class DeviceTestCase(TestCase):
+
+    def setUp(self):
+
+        self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        self.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+        )
+        self.device_role = DeviceRole.objects.create(
+            name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+        )
+
+        # Create DeviceType components
+        ConsolePortTemplate(
+            device_type=self.device_type,
+            name='Console Port 1'
+        ).save()
+
+        ConsoleServerPortTemplate(
+            device_type=self.device_type,
+            name='Console Server Port 1'
+        ).save()
+
+        ppt = PowerPortTemplate(
+            device_type=self.device_type,
+            name='Power Port 1',
+            maximum_draw=1000,
+            allocated_draw=500
+        )
+        ppt.save()
+
+        PowerOutletTemplate(
+            device_type=self.device_type,
+            name='Power Outlet 1',
+            power_port=ppt,
+            feed_leg=POWERFEED_LEG_A
+        ).save()
+
+        InterfaceTemplate(
+            device_type=self.device_type,
+            name='Interface 1',
+            type=IFACE_TYPE_1GE_FIXED,
+            mgmt_only=True
+        ).save()
+
+        rpt = RearPortTemplate(
+            device_type=self.device_type,
+            name='Rear Port 1',
+            type=PORT_TYPE_8P8C,
+            positions=8
+        )
+        rpt.save()
+
+        FrontPortTemplate(
+            device_type=self.device_type,
+            name='Front Port 1',
+            type=PORT_TYPE_8P8C,
+            rear_port=rpt,
+            rear_port_position=2
+        ).save()
+
+        DeviceBayTemplate(
+            device_type=self.device_type,
+            name='Device Bay 1'
+        ).save()
+
+    def test_device_creation(self):
+        """
+        Ensure that all Device components are copied automatically from the DeviceType.
+        """
+        d = Device(
+            site=self.site,
+            device_type=self.device_type,
+            device_role=self.device_role,
+            name='Test Device 1'
+        )
+        d.save()
+
+        ConsolePort.objects.get(
+            device=d,
+            name='Console Port 1'
+        )
+
+        ConsoleServerPort.objects.get(
+            device=d,
+            name='Console Server Port 1'
+        )
+
+        pp = PowerPort.objects.get(
+            device=d,
+            name='Power Port 1',
+            maximum_draw=1000,
+            allocated_draw=500
+        )
+
+        PowerOutlet.objects.get(
+            device=d,
+            name='Power Outlet 1',
+            power_port=pp,
+            feed_leg=POWERFEED_LEG_A
+        )
+
+        Interface.objects.get(
+            device=d,
+            name='Interface 1',
+            type=IFACE_TYPE_1GE_FIXED,
+            mgmt_only=True
+        )
+
+        rp = RearPort.objects.get(
+            device=d,
+            name='Rear Port 1',
+            type=PORT_TYPE_8P8C,
+            positions=8
+        )
+
+        FrontPort.objects.get(
+            device=d,
+            name='Front Port 1',
+            type=PORT_TYPE_8P8C,
+            rear_port=rp,
+            rear_port_position=2
+        )
+
+        DeviceBay.objects.get(
+            device=d,
+            name='Device Bay 1'
+        )
+
+
 class CableTestCase(TestCase):
 class CableTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):

+ 70 - 72
netbox/dcim/views.py

@@ -185,7 +185,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class SiteListView(PermissionRequiredMixin, ObjectListView):
 class SiteListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_site'
     permission_required = 'dcim.view_site'
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.prefetch_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     filter_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
@@ -197,7 +197,7 @@ class SiteView(PermissionRequiredMixin, View):
 
 
     def get(self, request, slug):
     def get(self, request, slug):
 
 
-        site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug)
+        site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug)
         stats = {
         stats = {
             'rack_count': Rack.objects.filter(site=site).count(),
             'rack_count': Rack.objects.filter(site=site).count(),
             'device_count': Device.objects.filter(site=site).count(),
             'device_count': Device.objects.filter(site=site).count(),
@@ -246,7 +246,7 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_site'
     permission_required = 'dcim.change_site'
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.prefetch_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     table = tables.SiteTable
     table = tables.SiteTable
     form = forms.SiteBulkEditForm
     form = forms.SiteBulkEditForm
@@ -255,7 +255,7 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_site'
     permission_required = 'dcim.delete_site'
-    queryset = Site.objects.select_related('region', 'tenant')
+    queryset = Site.objects.prefetch_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     table = tables.SiteTable
     table = tables.SiteTable
     default_return_url = 'dcim:site_list'
     default_return_url = 'dcim:site_list'
@@ -267,7 +267,7 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class RackGroupListView(PermissionRequiredMixin, ObjectListView):
 class RackGroupListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_rackgroup'
     permission_required = 'dcim.view_rackgroup'
-    queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
+    queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
     filter = filters.RackGroupFilter
     filter = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
     filter_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable
     table = tables.RackGroupTable
@@ -294,7 +294,7 @@ class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackgroup'
     permission_required = 'dcim.delete_rackgroup'
-    queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
+    queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
     filter = filters.RackGroupFilter
     filter = filters.RackGroupFilter
     table = tables.RackGroupTable
     table = tables.RackGroupTable
     default_return_url = 'dcim:rackgroup_list'
     default_return_url = 'dcim:rackgroup_list'
@@ -342,10 +342,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class RackListView(PermissionRequiredMixin, ObjectListView):
 class RackListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_rack'
     permission_required = 'dcim.view_rack'
-    queryset = Rack.objects.select_related(
-        'site', 'group', 'tenant', 'role'
-    ).prefetch_related(
-        'devices__device_type'
+    queryset = Rack.objects.prefetch_related(
+        'site', 'group', 'tenant', 'role', 'devices__device_type'
     ).annotate(
     ).annotate(
         device_count=Count('devices')
         device_count=Count('devices')
     )
     )
@@ -363,11 +361,7 @@ class RackElevationListView(PermissionRequiredMixin, View):
 
 
     def get(self, request):
     def get(self, request):
 
 
-        racks = Rack.objects.select_related(
-            'site', 'group', 'tenant', 'role'
-        ).prefetch_related(
-            'devices__device_type'
-        )
+        racks = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role', 'devices__device_type')
         racks = filters.RackFilter(request.GET, racks).qs
         racks = filters.RackFilter(request.GET, racks).qs
         total_count = racks.count()
         total_count = racks.count()
 
 
@@ -402,15 +396,18 @@ class RackView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
+        rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
 
 
-        nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True) \
-            .select_related('device_type__manufacturer')
+        nonracked_devices = Device.objects.filter(
+            rack=rack,
+            position__isnull=True,
+            parent_bay__isnull=True
+        ).prefetch_related('device_type__manufacturer')
         next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
         next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
         prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
         prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
 
 
         reservations = RackReservation.objects.filter(rack=rack)
         reservations = RackReservation.objects.filter(rack=rack)
-        power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel')
+        power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
 
 
         return render(request, 'dcim/rack.html', {
         return render(request, 'dcim/rack.html', {
             'rack': rack,
             'rack': rack,
@@ -451,7 +448,7 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rack'
     permission_required = 'dcim.change_rack'
-    queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
+    queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
     filter = filters.RackFilter
     filter = filters.RackFilter
     table = tables.RackTable
     table = tables.RackTable
     form = forms.RackBulkEditForm
     form = forms.RackBulkEditForm
@@ -460,7 +457,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rack'
     permission_required = 'dcim.delete_rack'
-    queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
+    queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
     filter = filters.RackFilter
     filter = filters.RackFilter
     table = tables.RackTable
     table = tables.RackTable
     default_return_url = 'dcim:rack_list'
     default_return_url = 'dcim:rack_list'
@@ -472,7 +469,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class RackReservationListView(PermissionRequiredMixin, ObjectListView):
 class RackReservationListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_rackreservation'
     permission_required = 'dcim.view_rackreservation'
-    queryset = RackReservation.objects.select_related('rack__site')
+    queryset = RackReservation.objects.prefetch_related('rack__site')
     filter = filters.RackReservationFilter
     filter = filters.RackReservationFilter
     filter_form = forms.RackReservationFilterForm
     filter_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
     table = tables.RackReservationTable
@@ -508,7 +505,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rackreservation'
     permission_required = 'dcim.change_rackreservation'
-    queryset = RackReservation.objects.select_related('rack', 'user')
+    queryset = RackReservation.objects.prefetch_related('rack', 'user')
     filter = filters.RackReservationFilter
     filter = filters.RackReservationFilter
     table = tables.RackReservationTable
     table = tables.RackReservationTable
     form = forms.RackReservationBulkEditForm
     form = forms.RackReservationBulkEditForm
@@ -517,7 +514,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     permission_required = 'dcim.delete_rackreservation'
-    queryset = RackReservation.objects.select_related('rack', 'user')
+    queryset = RackReservation.objects.prefetch_related('rack', 'user')
     filter = filters.RackReservationFilter
     filter = filters.RackReservationFilter
     table = tables.RackReservationTable
     table = tables.RackReservationTable
     default_return_url = 'dcim:rackreservation_list'
     default_return_url = 'dcim:rackreservation_list'
@@ -569,7 +566,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
 class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_devicetype'
     permission_required = 'dcim.view_devicetype'
-    queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
+    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter = filters.DeviceTypeFilter
     filter_form = forms.DeviceTypeFilterForm
     filter_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
@@ -666,7 +663,7 @@ class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
 class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_devicetype'
     permission_required = 'dcim.change_devicetype'
-    queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
+    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter = filters.DeviceTypeFilter
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
     form = forms.DeviceTypeBulkEditForm
     form = forms.DeviceTypeBulkEditForm
@@ -675,7 +672,7 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicetype'
     permission_required = 'dcim.delete_devicetype'
-    queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
+    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter = filters.DeviceTypeFilter
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
     default_return_url = 'dcim:devicetype_list'
     default_return_url = 'dcim:devicetype_list'
@@ -907,7 +904,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class DeviceListView(PermissionRequiredMixin, ObjectListView):
 class DeviceListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_device'
     permission_required = 'dcim.view_device'
-    queryset = Device.objects.select_related(
+    queryset = Device.objects.prefetch_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
         'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
     )
     )
     filter = filters.DeviceFilter
     filter = filters.DeviceFilter
@@ -921,7 +918,7 @@ class DeviceView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        device = get_object_or_404(Device.objects.select_related(
+        device = get_object_or_404(Device.objects.prefetch_related(
             'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
             'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
         ), pk=pk)
         ), pk=pk)
 
 
@@ -934,32 +931,31 @@ class DeviceView(PermissionRequiredMixin, View):
             vc_members = []
             vc_members = []
 
 
         # Console ports
         # Console ports
-        console_ports = device.consoleports.select_related('connected_endpoint__device', 'cable')
+        console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable')
 
 
         # Console server ports
         # Console server ports
-        consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable')
+        consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable')
 
 
         # Power ports
         # Power ports
-        power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable')
+        power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable')
 
 
         # Power outlets
         # Power outlets
-        poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable', 'power_port')
+        poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port')
 
 
         # Interfaces
         # Interfaces
-        interfaces = device.vc_interfaces.select_related(
-            'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable'
-        ).prefetch_related(
+        interfaces = device.vc_interfaces.prefetch_related(
+            'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
             'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
             'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
         )
         )
 
 
         # Front ports
         # Front ports
-        front_ports = device.frontports.select_related('rear_port', 'cable')
+        front_ports = device.frontports.prefetch_related('rear_port', 'cable')
 
 
         # Rear ports
         # Rear ports
-        rear_ports = device.rearports.select_related('cable')
+        rear_ports = device.rearports.prefetch_related('cable')
 
 
         # Device bays
         # Device bays
-        device_bays = device.device_bays.select_related('installed_device__device_type__manufacturer')
+        device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer')
 
 
         # Services
         # Services
         services = device.services.all()
         services = device.services.all()
@@ -972,7 +968,7 @@ class DeviceView(PermissionRequiredMixin, View):
             site=device.site, device_role=device.device_role
             site=device.site, device_role=device.device_role
         ).exclude(
         ).exclude(
             pk=device.pk
             pk=device.pk
-        ).select_related(
+        ).prefetch_related(
             'rack', 'device_type__manufacturer'
             'rack', 'device_type__manufacturer'
         )[:10]
         )[:10]
 
 
@@ -1005,10 +1001,8 @@ class DeviceInventoryView(PermissionRequiredMixin, View):
         device = get_object_or_404(Device, pk=pk)
         device = get_object_or_404(Device, pk=pk)
         inventory_items = InventoryItem.objects.filter(
         inventory_items = InventoryItem.objects.filter(
             device=device, parent=None
             device=device, parent=None
-        ).select_related(
-            'manufacturer'
         ).prefetch_related(
         ).prefetch_related(
-            'child_items'
+            'manufacturer', 'child_items'
         )
         )
 
 
         return render(request, 'dcim/device_inventory.html', {
         return render(request, 'dcim/device_inventory.html', {
@@ -1037,7 +1031,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         device = get_object_or_404(Device, pk=pk)
         device = get_object_or_404(Device, pk=pk)
-        interfaces = device.vc_interfaces.connectable().select_related(
+        interfaces = device.vc_interfaces.connectable().prefetch_related(
             '_connected_interface__device'
             '_connected_interface__device'
         )
         )
 
 
@@ -1114,7 +1108,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_device'
     permission_required = 'dcim.change_device'
-    queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
+    queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
     filter = filters.DeviceFilter
     filter = filters.DeviceFilter
     table = tables.DeviceTable
     table = tables.DeviceTable
     form = forms.DeviceBulkEditForm
     form = forms.DeviceBulkEditForm
@@ -1123,7 +1117,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_device'
     permission_required = 'dcim.delete_device'
-    queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
+    queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
     filter = filters.DeviceFilter
     filter = filters.DeviceFilter
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
@@ -1310,7 +1304,7 @@ class InterfaceView(PermissionRequiredMixin, View):
 
 
         # Get assigned IP addresses
         # Get assigned IP addresses
         ipaddress_table = InterfaceIPAddressTable(
         ipaddress_table = InterfaceIPAddressTable(
-            data=interface.ip_addresses.select_related('vrf', 'tenant'),
+            data=interface.ip_addresses.prefetch_related('vrf', 'tenant'),
             orderable=False
             orderable=False
         )
         )
 
 
@@ -1319,7 +1313,7 @@ class InterfaceView(PermissionRequiredMixin, View):
         if interface.untagged_vlan is not None:
         if interface.untagged_vlan is not None:
             vlans.append(interface.untagged_vlan)
             vlans.append(interface.untagged_vlan)
             vlans[0].tagged = False
             vlans[0].tagged = False
-        for vlan in interface.tagged_vlans.select_related('site', 'group', 'tenant', 'role'):
+        for vlan in interface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'):
             vlan.tagged = True
             vlan.tagged = True
             vlans.append(vlan)
             vlans.append(vlan)
         vlan_table = InterfaceVLANTable(
         vlan_table = InterfaceVLANTable(
@@ -1842,7 +1836,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
 class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
     permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
     permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
-    queryset = ConsolePort.objects.select_related(
+    queryset = ConsolePort.objects.prefetch_related(
         'device', 'connected_endpoint__device'
         'device', 'connected_endpoint__device'
     ).filter(
     ).filter(
         connected_endpoint__isnull=False
         connected_endpoint__isnull=False
@@ -1873,7 +1867,7 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
 
 
 class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
 class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
     permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
     permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
-    queryset = PowerPort.objects.select_related(
+    queryset = PowerPort.objects.prefetch_related(
         'device', '_connected_poweroutlet__device'
         'device', '_connected_poweroutlet__device'
     ).filter(
     ).filter(
         _connected_poweroutlet__isnull=False
         _connected_poweroutlet__isnull=False
@@ -1904,7 +1898,7 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
 
 
 class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
 class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_interface'
     permission_required = 'dcim.view_interface'
-    queryset = Interface.objects.select_related(
+    queryset = Interface.objects.prefetch_related(
         'device', 'cable', '_connected_interface__device'
         'device', 'cable', '_connected_interface__device'
     ).filter(
     ).filter(
         # Avoid duplicate connections by only selecting the lower PK in a connected pair
         # Avoid duplicate connections by only selecting the lower PK in a connected pair
@@ -1947,7 +1941,7 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
 
 
 class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
 class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_inventoryitem'
     permission_required = 'dcim.view_inventoryitem'
-    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
     filter = filters.InventoryItemFilter
     filter = filters.InventoryItemFilter
     filter_form = forms.InventoryItemFilterForm
     filter_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
@@ -1982,7 +1976,7 @@ class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_inventoryitem'
     permission_required = 'dcim.change_inventoryitem'
-    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
     filter = filters.InventoryItemFilter
     filter = filters.InventoryItemFilter
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     form = forms.InventoryItemBulkEditForm
     form = forms.InventoryItemBulkEditForm
@@ -1991,7 +1985,7 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_inventoryitem'
     permission_required = 'dcim.delete_inventoryitem'
-    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     template_name = 'dcim/inventoryitem_bulk_delete.html'
     template_name = 'dcim/inventoryitem_bulk_delete.html'
     default_return_url = 'dcim:inventoryitem_list'
     default_return_url = 'dcim:inventoryitem_list'
@@ -2003,7 +1997,7 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
 class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_virtualchassis'
     permission_required = 'dcim.view_virtualchassis'
-    queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members'))
+    queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members'))
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
     filter = filters.VirtualChassisFilter
     filter = filters.VirtualChassisFilter
     filter_form = forms.VirtualChassisFilterForm
     filter_form = forms.VirtualChassisFilterForm
@@ -2023,7 +2017,7 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
             return redirect('dcim:device_list')
             return redirect('dcim:device_list')
         device_queryset = Device.objects.filter(
         device_queryset = Device.objects.filter(
             pk__in=pk_form.cleaned_data.get('pk')
             pk__in=pk_form.cleaned_data.get('pk')
-        ).select_related('rack').order_by('vc_position')
+        ).prefetch_related('rack').order_by('vc_position')
 
 
         VCMemberFormSet = modelformset_factory(
         VCMemberFormSet = modelformset_factory(
             model=Device,
             model=Device,
@@ -2077,7 +2071,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
             formset=forms.BaseVCMemberFormSet,
             formset=forms.BaseVCMemberFormSet,
             extra=0
             extra=0
         )
         )
-        members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
+        members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position')
 
 
         vc_form = forms.VirtualChassisForm(instance=virtual_chassis)
         vc_form = forms.VirtualChassisForm(instance=virtual_chassis)
         vc_form.fields['master'].queryset = members_queryset
         vc_form.fields['master'].queryset = members_queryset
@@ -2098,7 +2092,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
             formset=forms.BaseVCMemberFormSet,
             formset=forms.BaseVCMemberFormSet,
             extra=0
             extra=0
         )
         )
-        members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
+        members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position')
 
 
         vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis)
         vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis)
         vc_form.fields['master'].queryset = members_queryset
         vc_form.fields['master'].queryset = members_queryset
@@ -2114,7 +2108,10 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
                 # Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on
                 # Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on
                 # duplicate positions. Then save each member instance.
                 # duplicate positions. Then save each member instance.
                 members = formset.save(commit=False)
                 members = formset.save(commit=False)
-                Device.objects.filter(pk__in=[m.pk for m in members]).update(vc_position=None)
+                devices = Device.objects.filter(pk__in=[m.pk for m in members])
+                for device in devices:
+                    device.vc_position = None
+                    device.save()
                 for member in members:
                 for member in members:
                     member.save()
                     member.save()
 
 
@@ -2215,11 +2212,12 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
 
 
         if form.is_valid():
         if form.is_valid():
 
 
-            Device.objects.filter(pk=device.pk).update(
-                virtual_chassis=None,
-                vc_position=None,
-                vc_priority=None
-            )
+            devices = Device.objects.filter(pk=device.pk)
+            for device in devices:
+                device.virtual_chassis = None
+                device.vc_position = None
+                device.vc_priority = None
+                device.save()
 
 
             msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
             msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
             messages.success(request, msg)
             messages.success(request, msg)
@@ -2239,7 +2237,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
 
 
 class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
 class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_powerpanel'
     permission_required = 'dcim.view_powerpanel'
-    queryset = PowerPanel.objects.select_related(
+    queryset = PowerPanel.objects.prefetch_related(
         'site', 'rack_group'
         'site', 'rack_group'
     ).annotate(
     ).annotate(
         powerfeed_count=Count('powerfeeds')
         powerfeed_count=Count('powerfeeds')
@@ -2255,9 +2253,9 @@ class PowerPanelView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        powerpanel = get_object_or_404(PowerPanel.objects.select_related('site', 'rack_group'), pk=pk)
+        powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk)
         powerfeed_table = tables.PowerFeedTable(
         powerfeed_table = tables.PowerFeedTable(
-            data=PowerFeed.objects.filter(power_panel=powerpanel).select_related('rack'),
+            data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'),
             orderable=False
             orderable=False
         )
         )
         powerfeed_table.exclude = ['power_panel']
         powerfeed_table.exclude = ['power_panel']
@@ -2294,7 +2292,7 @@ class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerpanel'
     permission_required = 'dcim.delete_powerpanel'
-    queryset = PowerPanel.objects.select_related(
+    queryset = PowerPanel.objects.prefetch_related(
         'site', 'rack_group'
         'site', 'rack_group'
     ).annotate(
     ).annotate(
         rack_count=Count('powerfeeds')
         rack_count=Count('powerfeeds')
@@ -2310,7 +2308,7 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
 class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_powerfeed'
     permission_required = 'dcim.view_powerfeed'
-    queryset = PowerFeed.objects.select_related(
+    queryset = PowerFeed.objects.prefetch_related(
         'power_panel', 'rack'
         'power_panel', 'rack'
     )
     )
     filter = filters.PowerFeedFilter
     filter = filters.PowerFeedFilter
@@ -2324,7 +2322,7 @@ class PowerFeedView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        powerfeed = get_object_or_404(PowerFeed.objects.select_related('power_panel', 'rack'), pk=pk)
+        powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk)
 
 
         return render(request, 'dcim/powerfeed.html', {
         return render(request, 'dcim/powerfeed.html', {
             'powerfeed': powerfeed,
             'powerfeed': powerfeed,
@@ -2358,7 +2356,7 @@ class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_powerfeed'
     permission_required = 'dcim.change_powerfeed'
-    queryset = PowerFeed.objects.select_related('power_panel', 'rack')
+    queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
     filter = filters.PowerFeedFilter
     filter = filters.PowerFeedFilter
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable
     form = forms.PowerFeedBulkEditForm
     form = forms.PowerFeedBulkEditForm
@@ -2367,7 +2365,7 @@ class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerfeed'
     permission_required = 'dcim.delete_powerfeed'
-    queryset = PowerFeed.objects.select_related('power_panel', 'rack')
+    queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
     filter = filters.PowerFeedFilter
     filter = filters.PowerFeedFilter
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable
     default_return_url = 'dcim:powerfeed_list'
     default_return_url = 'dcim:powerfeed_list'

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

@@ -120,7 +120,7 @@ class ExportTemplateViewSet(ModelViewSet):
 #
 #
 
 
 class TopologyMapViewSet(ModelViewSet):
 class TopologyMapViewSet(ModelViewSet):
-    queryset = TopologyMap.objects.select_related('site')
+    queryset = TopologyMap.objects.prefetch_related('site')
     serializer_class = serializers.TopologyMapSerializer
     serializer_class = serializers.TopologyMapSerializer
     filterset_class = filters.TopologyMapFilter
     filterset_class = filters.TopologyMapFilter
 
 
@@ -260,6 +260,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     """
     """
     Retrieve a list of recent changes.
     Retrieve a list of recent changes.
     """
     """
-    queryset = ObjectChange.objects.select_related('user')
+    queryset = ObjectChange.objects.prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filters.ObjectChangeFilter
     filterset_class = filters.ObjectChangeFilter

+ 58 - 7
netbox/extras/forms.py

@@ -8,9 +8,10 @@ from taggit.forms import TagField
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.constants import COLOR_CHOICES
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField,
-    ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField,
+    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
+    CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField,
 )
 )
 from .constants import (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
@@ -111,8 +112,10 @@ class CustomFieldForm(forms.ModelForm):
 
 
         # If editing an existing object, initialize values for all custom fields
         # If editing an existing object, initialize values for all custom fields
         if self.instance.pk:
         if self.instance.pk:
-            existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\
-                .select_related('field')
+            existing_values = CustomFieldValue.objects.filter(
+                obj_type=self.obj_type,
+                obj_id=self.instance.pk
+            ).prefetch_related('field')
             for cfv in existing_values:
             for cfv in existing_values:
                 self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
                 self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
 
 
@@ -120,9 +123,11 @@ class CustomFieldForm(forms.ModelForm):
 
 
         for field_name in self.custom_fields:
         for field_name in self.custom_fields:
             try:
             try:
-                cfv = CustomFieldValue.objects.select_related('field').get(field=self.fields[field_name].model,
-                                                                           obj_type=self.obj_type,
-                                                                           obj_id=self.instance.pk)
+                cfv = CustomFieldValue.objects.prefetch_related('field').get(
+                    field=self.fields[field_name].model,
+                    obj_type=self.obj_type,
+                    obj_id=self.instance.pk
+                )
             except CustomFieldValue.DoesNotExist:
             except CustomFieldValue.DoesNotExist:
                 # Skip this field if none exists already and its value is empty
                 # Skip this field if none exists already and its value is empty
                 if self.cleaned_data[field_name] in [None, '']:
                 if self.cleaned_data[field_name] in [None, '']:
@@ -215,6 +220,21 @@ class TagFilterForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
+class TagBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    color = forms.CharField(
+        max_length=6,
+        required=False,
+        widget=ColorSelect()
+    )
+
+    class Meta:
+        nullable_fields = []
+
+
 #
 #
 # Config contexts
 # Config contexts
 #
 #
@@ -380,3 +400,34 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
         widget=ContentTypeSelect(),
         widget=ContentTypeSelect(),
         label='Object Type'
         label='Object Type'
     )
     )
+
+
+#
+# Scripts
+#
+
+class ScriptForm(BootstrapMixin, forms.Form):
+    _commit = forms.BooleanField(
+        required=False,
+        initial=True,
+        label="Commit changes",
+        help_text="Commit changes to the database (uncheck for a dry-run)"
+    )
+
+    def __init__(self, vars, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        # Dynamically populate fields for variables
+        for name, var in vars.items():
+            self.fields[name] = var.as_field()
+
+        # Move _commit to the end of the form
+        self.fields.move_to_end('_commit', True)
+
+    @property
+    def requires_input(self):
+        """
+        A boolean indicating whether the form requires user input (ignore the _commit field).
+        """
+        return bool(len(self.fields) > 1)

+ 4 - 0
netbox/extras/management/commands/nbshell.py

@@ -5,6 +5,7 @@ import sys
 from django import get_version
 from django import get_version
 from django.apps import apps
 from django.apps import apps
 from django.conf import settings
 from django.conf import settings
+from django.contrib.auth.models import User
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 
 
 APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
 APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
@@ -50,6 +51,9 @@ class Command(BaseCommand):
             except KeyError:
             except KeyError:
                 pass
                 pass
 
 
+        # Additional objects to include
+        namespace['User'] = User
+
         # Load convenience commands
         # Load convenience commands
         namespace.update({
         namespace.update({
             'lsmodels': self._lsmodels,
             'lsmodels': self._lsmodels,

+ 47 - 32
netbox/extras/middleware.py

@@ -6,39 +6,46 @@ from datetime import timedelta
 from django.conf import settings
 from django.conf import settings
 from django.db.models.signals import post_delete, post_save
 from django.db.models.signals import post_delete, post_save
 from django.utils import timezone
 from django.utils import timezone
-from django.utils.functional import curry
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
-from extras.webhooks import enqueue_webhooks
 from .constants import (
 from .constants import (
     OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
     OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
 )
 )
 from .models import ObjectChange
 from .models import ObjectChange
+from .signals import purge_changelog
+from .webhooks import enqueue_webhooks
 
 
 _thread_locals = threading.local()
 _thread_locals = threading.local()
 
 
 
 
-def cache_changed_object(instance, **kwargs):
-
-    action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
-
-    # Cache the object for further processing was the response has completed.
-    _thread_locals.changed_objects.append(
-        (instance, action)
-    )
-
+def cache_changed_object(sender, instance, **kwargs):
+    """
+    Cache an object being created or updated for the changelog.
+    """
+    if hasattr(instance, 'to_objectchange'):
+        action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
+        objectchange = instance.to_objectchange(action)
+        _thread_locals.changed_objects.append(
+            (instance, objectchange)
+        )
 
 
-def _record_object_deleted(request, instance, **kwargs):
 
 
-    # Record that the object was deleted
-    if hasattr(instance, 'log_change'):
-        instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
+def cache_deleted_object(sender, instance, **kwargs):
+    """
+    Cache an object being deleted for the changelog.
+    """
+    if hasattr(instance, 'to_objectchange'):
+        objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
+        _thread_locals.changed_objects.append(
+            (instance, objectchange)
+        )
 
 
-    # Enqueue webhooks
-    enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
 
 
-    # Increment metric counters
-    model_deletes.labels(instance._meta.model_name).inc()
+def purge_objectchange_cache(sender, **kwargs):
+    """
+    Delete any queued object changes waiting to be written.
+    """
+    _thread_locals.changed_objects = []
 
 
 
 
 class ObjectChangeMiddleware(object):
 class ObjectChangeMiddleware(object):
@@ -67,34 +74,42 @@ class ObjectChangeMiddleware(object):
         # the same request.
         # the same request.
         request.id = uuid.uuid4()
         request.id = uuid.uuid4()
 
 
-        # Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
-        record_object_deleted = curry(_record_object_deleted, request)
-
         # Connect our receivers to the post_save and post_delete signals.
         # Connect our receivers to the post_save and post_delete signals.
-        post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
-        post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
+        post_save.connect(cache_changed_object, dispatch_uid='cache_changed_object')
+        post_delete.connect(cache_deleted_object, dispatch_uid='cache_deleted_object')
+
+        # Provide a hook for purging the change cache
+        purge_changelog.connect(purge_objectchange_cache)
 
 
         # Process the request
         # Process the request
         response = self.get_response(request)
         response = self.get_response(request)
 
 
+        # If the change cache is empty, there's nothing more we need to do.
+        if not _thread_locals.changed_objects:
+            return response
+
         # Create records for any cached objects that were created/updated.
         # Create records for any cached objects that were created/updated.
-        for obj, action in _thread_locals.changed_objects:
+        for obj, objectchange in _thread_locals.changed_objects:
 
 
             # Record the change
             # Record the change
-            if hasattr(obj, 'log_change'):
-                obj.log_change(request.user, request.id, action)
+            objectchange.user = request.user
+            objectchange.request_id = request.id
+            objectchange.save()
 
 
             # Enqueue webhooks
             # Enqueue webhooks
-            enqueue_webhooks(obj, request.user, request.id, action)
+            enqueue_webhooks(obj, request.user, request.id, objectchange.action)
 
 
             # Increment metric counters
             # Increment metric counters
-            if action == OBJECTCHANGE_ACTION_CREATE:
+            if objectchange.action == OBJECTCHANGE_ACTION_CREATE:
                 model_inserts.labels(obj._meta.model_name).inc()
                 model_inserts.labels(obj._meta.model_name).inc()
-            elif action == OBJECTCHANGE_ACTION_UPDATE:
+            elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
                 model_updates.labels(obj._meta.model_name).inc()
                 model_updates.labels(obj._meta.model_name).inc()
+            elif objectchange.action == OBJECTCHANGE_ACTION_DELETE:
+                model_deletes.labels(obj._meta.model_name).inc()
 
 
-        # Housekeeping: 1% chance of clearing out expired ObjectChanges
-        if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
+        # Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
+        # one or more changes being logged.
+        if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
             cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
             cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
             purged_count, _ = ObjectChange.objects.filter(
             purged_count, _ = ObjectChange.objects.filter(
                 time__lt=cutoff
                 time__lt=cutoff

+ 23 - 0
netbox/extras/migrations/0024_scripts.py

@@ -0,0 +1,23 @@
+# Generated by Django 2.2 on 2019-08-12 15:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0023_fix_tag_sequences'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Script',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+            ],
+            options={
+                'permissions': (('run_script', 'Can run script'),),
+                'managed': False,
+            },
+        ),
+    ]

+ 18 - 0
netbox/extras/migrations/0025_objectchange_time_index.py

@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2019-08-28 14:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0024_scripts'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='objectchange',
+            name='time',
+            field=models.DateTimeField(auto_now_add=True, db_index=True),
+        ),
+    ]

+ 23 - 5
netbox/extras/models.py

@@ -569,7 +569,7 @@ class TopologyMap(models.Model):
             # Add each device to the graph
             # Add each device to the graph
             devices = []
             devices = []
             for query in device_set.strip(';').split(';'):  # Split regexes on semicolons
             for query in device_set.strip(';').split(';'):  # Split regexes on semicolons
-                devices += Device.objects.filter(name__regex=query).select_related('device_role')
+                devices += Device.objects.filter(name__regex=query).prefetch_related('device_role')
             # Remove duplicate devices
             # Remove duplicate devices
             devices = [d for d in devices if d.id not in seen]
             devices = [d for d in devices if d.id not in seen]
             seen.update([d.id for d in devices])
             seen.update([d.id for d in devices])
@@ -607,7 +607,7 @@ class TopologyMap(models.Model):
         from dcim.models import Interface
         from dcim.models import Interface
 
 
         # Add all interface connections to the graph
         # Add all interface connections to the graph
-        connected_interfaces = Interface.objects.select_related(
+        connected_interfaces = Interface.objects.prefetch_related(
             '_connected_interface__device'
             '_connected_interface__device'
         ).filter(
         ).filter(
             Q(device__in=devices) | Q(_connected_interface__device__in=devices),
             Q(device__in=devices) | Q(_connected_interface__device__in=devices),
@@ -826,6 +826,21 @@ class ConfigContextModel(models.Model):
         return data
         return data
 
 
 
 
+#
+# Custom scripts
+#
+
+class Script(models.Model):
+    """
+    Dummy model used to generate permissions for custom scripts. Does not exist in the database.
+    """
+    class Meta:
+        managed = False
+        permissions = (
+            ('run_script', 'Can run script'),
+        )
+
+
 #
 #
 # Report results
 # Report results
 #
 #
@@ -867,7 +882,8 @@ class ObjectChange(models.Model):
     """
     """
     time = models.DateTimeField(
     time = models.DateTimeField(
         auto_now_add=True,
         auto_now_add=True,
-        editable=False
+        editable=False,
+        db_index=True
     )
     )
     user = models.ForeignKey(
     user = models.ForeignKey(
         to=User,
         to=User,
@@ -938,8 +954,10 @@ class ObjectChange(models.Model):
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
         # Record the user's name and the object's representation as static strings
         # Record the user's name and the object's representation as static strings
-        self.user_name = self.user.username
-        self.object_repr = str(self.changed_object)
+        if not self.user_name:
+            self.user_name = self.user.username
+        if not self.object_repr:
+            self.object_repr = str(self.changed_object)
 
 
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 

+ 343 - 0
netbox/extras/scripts.py

@@ -0,0 +1,343 @@
+from collections import OrderedDict
+import inspect
+import json
+import os
+import pkgutil
+import time
+import traceback
+import yaml
+
+from django import forms
+from django.conf import settings
+from django.core.validators import RegexValidator
+from django.db import transaction
+from mptt.forms import TreeNodeChoiceField
+from mptt.models import MPTTModel
+
+from ipam.formfields import IPFormField
+from utilities.exceptions import AbortTransaction
+from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
+from .forms import ScriptForm
+from .signals import purge_changelog
+
+
+__all__ = [
+    'BaseScript',
+    'BooleanVar',
+    'FileVar',
+    'IntegerVar',
+    'IPNetworkVar',
+    'ObjectVar',
+    'Script',
+    'StringVar',
+    'TextVar',
+]
+
+
+#
+# Script variables
+#
+
+class ScriptVariable:
+    """
+    Base model for script variables
+    """
+    form_field = forms.CharField
+
+    def __init__(self, label='', description='', default=None, required=True):
+
+        # Default field attributes
+        self.field_attrs = {
+            'help_text': description,
+            'required': required
+        }
+        if label:
+            self.field_attrs['label'] = label
+        if default:
+            self.field_attrs['initial'] = default
+
+    def as_field(self):
+        """
+        Render the variable as a Django form field.
+        """
+        form_field = self.form_field(**self.field_attrs)
+        form_field.widget.attrs['class'] = 'form-control'
+
+        return form_field
+
+
+class StringVar(ScriptVariable):
+    """
+    Character string representation. Can enforce minimum/maximum length and/or regex validation.
+    """
+    def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Optional minimum/maximum lengths
+        if min_length:
+            self.field_attrs['min_length'] = min_length
+        if max_length:
+            self.field_attrs['max_length'] = max_length
+
+        # Optional regular expression validation
+        if regex:
+            self.field_attrs['validators'] = [
+                RegexValidator(
+                    regex=regex,
+                    message='Invalid value. Must match regex: {}'.format(regex),
+                    code='invalid'
+                )
+            ]
+
+
+class TextVar(ScriptVariable):
+    """
+    Free-form text data. Renders as a <textarea>.
+    """
+    form_field = forms.CharField
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.field_attrs['widget'] = forms.Textarea
+
+
+class IntegerVar(ScriptVariable):
+    """
+    Integer representation. Can enforce minimum/maximum values.
+    """
+    form_field = forms.IntegerField
+
+    def __init__(self, min_value=None, max_value=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Optional minimum/maximum values
+        if min_value:
+            self.field_attrs['min_value'] = min_value
+        if max_value:
+            self.field_attrs['max_value'] = max_value
+
+
+class BooleanVar(ScriptVariable):
+    """
+    Boolean representation (true/false). Renders as a checkbox.
+    """
+    form_field = forms.BooleanField
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Boolean fields cannot be required
+        self.field_attrs['required'] = False
+
+
+class ObjectVar(ScriptVariable):
+    """
+    NetBox object representation. The provided QuerySet will determine the choices available.
+    """
+    form_field = forms.ModelChoiceField
+
+    def __init__(self, queryset, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Queryset for field choices
+        self.field_attrs['queryset'] = queryset
+
+        # Update form field for MPTT (nested) objects
+        if issubclass(queryset.model, MPTTModel):
+            self.form_field = TreeNodeChoiceField
+
+
+class FileVar(ScriptVariable):
+    """
+    An uploaded file.
+    """
+    form_field = forms.FileField
+
+
+class IPNetworkVar(ScriptVariable):
+    """
+    An IPv4 or IPv6 prefix.
+    """
+    form_field = IPFormField
+
+
+#
+# Scripts
+#
+
+class BaseScript:
+    """
+    Base model for custom scripts. User classes should inherit from this model if they want to extend Script
+    functionality for use in other subclasses.
+    """
+    class Meta:
+        pass
+
+    def __init__(self):
+
+        # Initiate the log
+        self.log = []
+
+        # Grab some info about the script
+        self.filename = inspect.getfile(self.__class__)
+        self.source = inspect.getsource(self.__class__)
+
+    def __str__(self):
+        return getattr(self.Meta, 'name', self.__class__.__name__)
+
+    def _get_vars(self):
+        vars = OrderedDict()
+
+        # Infer order from Meta.field_order (Python 3.5 and lower)
+        field_order = getattr(self.Meta, 'field_order', [])
+        for name in field_order:
+            vars[name] = getattr(self, name)
+
+        # Default to order of declaration on class
+        for name, attr in self.__class__.__dict__.items():
+            if name not in vars and issubclass(attr.__class__, ScriptVariable):
+                vars[name] = attr
+
+        return vars
+
+    def run(self, data):
+        raise NotImplementedError("The script must define a run() method.")
+
+    def as_form(self, data=None, files=None):
+        """
+        Return a Django form suitable for populating the context data required to run this Script.
+        """
+        vars = self._get_vars()
+        form = ScriptForm(vars, data, files)
+
+        return form
+
+    # Logging
+
+    def log_debug(self, message):
+        self.log.append((LOG_DEFAULT, message))
+
+    def log_success(self, message):
+        self.log.append((LOG_SUCCESS, message))
+
+    def log_info(self, message):
+        self.log.append((LOG_INFO, message))
+
+    def log_warning(self, message):
+        self.log.append((LOG_WARNING, message))
+
+    def log_failure(self, message):
+        self.log.append((LOG_FAILURE, message))
+
+    # Convenience functions
+
+    def load_yaml(self, filename):
+        """
+        Return data from a YAML file
+        """
+        file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
+        with open(file_path, 'r') as datafile:
+            data = yaml.load(datafile)
+
+        return data
+
+    def load_json(self, filename):
+        """
+        Return data from a JSON file
+        """
+        file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
+        with open(file_path, 'r') as datafile:
+            data = json.load(datafile)
+
+        return data
+
+
+class Script(BaseScript):
+    """
+    Classes which inherit this model will appear in the list of available scripts.
+    """
+    pass
+
+
+#
+# Functions
+#
+
+def is_script(obj):
+    """
+    Returns True if the object is a Script.
+    """
+    try:
+        return issubclass(obj, Script) and obj != Script
+    except TypeError:
+        return False
+
+
+def is_variable(obj):
+    """
+    Returns True if the object is a ScriptVariable.
+    """
+    return isinstance(obj, ScriptVariable)
+
+
+def run_script(script, data, files, commit=True):
+    """
+    A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
+    exists outside of the Script class to ensure it cannot be overridden by a script author.
+    """
+    output = None
+    start_time = None
+    end_time = None
+
+    # Add files to form data
+    for field_name, fileobj in files.items():
+        data[field_name] = fileobj
+
+    try:
+        with transaction.atomic():
+            start_time = time.time()
+            output = script.run(data)
+            end_time = time.time()
+            if not commit:
+                raise AbortTransaction()
+    except AbortTransaction:
+        pass
+    except Exception as e:
+        stacktrace = traceback.format_exc()
+        script.log_failure(
+            "An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
+        )
+        commit = False
+    finally:
+        if not commit:
+            # Delete all pending changelog entries
+            purge_changelog.send(Script)
+            script.log_info(
+                "Database changes have been reverted automatically."
+            )
+
+    # Calculate execution time
+    if end_time is not None:
+        execution_time = end_time - start_time
+    else:
+        execution_time = None
+
+    return output, execution_time
+
+
+def get_scripts():
+    scripts = OrderedDict()
+
+    # Iterate through all modules within the reports path. These are the user-created files in which reports are
+    # defined.
+    for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
+        module = importer.find_module(module_name).load_module(module_name)
+        if hasattr(module, 'name'):
+            module_name = module.name
+        module_scripts = OrderedDict()
+        for name, cls in inspect.getmembers(module, is_script):
+            module_scripts[name] = cls
+        scripts[module_name] = module_scripts
+
+    return scripts

+ 12 - 0
netbox/extras/signals.py

@@ -1,7 +1,12 @@
 from cacheops.signals import cache_invalidated, cache_read
 from cacheops.signals import cache_invalidated, cache_read
+from django.dispatch import Signal
 from prometheus_client import Counter
 from prometheus_client import Counter
 
 
 
 
+#
+# Caching
+#
+
 cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
 cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
 cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
 cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
 cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
 cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
@@ -20,3 +25,10 @@ def cache_invalidated_collector(sender, obj_dict, **kwargs):
 
 
 cache_read.connect(cache_read_collector)
 cache_read.connect(cache_read_collector)
 cache_invalidated.connect(cache_invalidated_collector)
 cache_invalidated.connect(cache_invalidated_collector)
+
+
+#
+# Change logging
+#
+
+purge_changelog = Signal()

+ 37 - 0
netbox/extras/templatetags/log_levels.py

@@ -0,0 +1,37 @@
+from django import template
+
+from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
+
+
+register = template.Library()
+
+
+@register.inclusion_tag('extras/templatetags/log_level.html')
+def log_level(level):
+    """
+    Display a label indicating a syslog severity (e.g. info, warning, etc.).
+    """
+    levels = {
+        LOG_DEFAULT: {
+            'name': 'Default',
+            'class': 'default'
+        },
+        LOG_SUCCESS: {
+            'name': 'Success',
+            'class': 'success',
+        },
+        LOG_INFO: {
+            'name': 'Info',
+            'class': 'info'
+        },
+        LOG_WARNING: {
+            'name': 'Warning',
+            'class': 'warning'
+        },
+        LOG_FAILURE: {
+            'name': 'Failure',
+            'class': 'danger'
+        }
+    }
+
+    return levels[level]

+ 72 - 0
netbox/extras/tests/test_changelog.py

@@ -0,0 +1,72 @@
+from django.urls import reverse
+from rest_framework import status
+
+from dcim.models import Site
+from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE, OBJECTCHANGE_ACTION_DELETE
+from extras.models import ObjectChange
+from utilities.testing import APITestCase
+
+
+class ChangeLogTest(APITestCase):
+
+    def test_create_object(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+        }
+
+        self.assertEqual(ObjectChange.objects.count(), 0)
+
+        url = reverse('dcim-api:site-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(ObjectChange.objects.count(), 1)
+
+        oc = ObjectChange.objects.first()
+        site = Site.objects.get(pk=response.data['id'])
+        self.assertEqual(oc.changed_object, site)
+        self.assertEqual(oc.action, OBJECTCHANGE_ACTION_CREATE)
+
+    def test_update_object(self):
+
+        site = Site(name='Test Site 1', slug='test-site-1')
+        site.save()
+
+        data = {
+            'name': 'Test Site X',
+            'slug': 'test-site-x',
+        }
+
+        self.assertEqual(ObjectChange.objects.count(), 0)
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(ObjectChange.objects.count(), 1)
+        site = Site.objects.get(pk=response.data['id'])
+        self.assertEqual(site.name, data['name'])
+
+        oc = ObjectChange.objects.first()
+        self.assertEqual(oc.changed_object, site)
+        self.assertEqual(oc.action, OBJECTCHANGE_ACTION_UPDATE)
+
+    def test_delete_object(self):
+
+        site = Site(name='Test Site 1', slug='test-site-1')
+        site.save()
+
+        self.assertEqual(ObjectChange.objects.count(), 0)
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(Site.objects.count(), 0)
+
+        oc = ObjectChange.objects.first()
+        self.assertEqual(oc.changed_object, None)
+        self.assertEqual(oc.object_repr, site.name)
+        self.assertEqual(oc.action, OBJECTCHANGE_ACTION_DELETE)

+ 157 - 0
netbox/extras/tests/test_scripts.py

@@ -0,0 +1,157 @@
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import TestCase
+from netaddr import IPNetwork
+
+from dcim.models import DeviceRole
+from extras.scripts import *
+
+
+class ScriptVariablesTest(TestCase):
+
+    def test_stringvar(self):
+
+        class TestScript(Script):
+
+            var1 = StringVar(
+                min_length=3,
+                max_length=3,
+                regex=r'[a-z]+'
+            )
+
+        # Validate min_length enforcement
+        data = {'var1': 'xx'}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate max_length enforcement
+        data = {'var1': 'xxxx'}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate regex enforcement
+        data = {'var1': 'ABC'}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate valid data
+        data = {'var1': 'abc'}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], data['var1'])
+
+    def test_textvar(self):
+
+        class TestScript(Script):
+
+            var1 = TextVar()
+
+        # Validate valid data
+        data = {'var1': 'This is a test string'}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], data['var1'])
+
+    def test_integervar(self):
+
+        class TestScript(Script):
+
+            var1 = IntegerVar(
+                min_value=5,
+                max_value=10
+            )
+
+        # Validate min_value enforcement
+        data = {'var1': 4}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate max_value enforcement
+        data = {'var1': 11}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate valid data
+        data = {'var1': 7}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], data['var1'])
+
+    def test_booleanvar(self):
+
+        class TestScript(Script):
+
+            var1 = BooleanVar()
+
+        # Validate True
+        data = {'var1': True}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], True)
+
+        # Validate False
+        data = {'var1': False}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], False)
+
+    def test_objectvar(self):
+
+        class TestScript(Script):
+
+            var1 = ObjectVar(
+                queryset=DeviceRole.objects.all()
+            )
+
+        # Populate some objects
+        for i in range(1, 6):
+            DeviceRole(
+                name='Device Role {}'.format(i),
+                slug='device-role-{}'.format(i)
+            ).save()
+
+        # Validate valid data
+        data = {'var1': DeviceRole.objects.first().pk}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'].pk, data['var1'])
+
+    def test_filevar(self):
+
+        class TestScript(Script):
+
+            var1 = FileVar()
+
+        # Dummy file
+        testfile = SimpleUploadedFile(
+            name='test_file.txt',
+            content=b'This is a dummy file for testing'
+        )
+
+        # Validate valid data
+        file_data = {'var1': testfile}
+        form = TestScript().as_form(None, file_data)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], testfile)
+
+    def test_ipnetworkvar(self):
+
+        class TestScript(Script):
+
+            var1 = IPNetworkVar()
+
+        # Validate IP network enforcement
+        data = {'var1': '1.2.3'}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate valid data
+        data = {'var1': '192.0.2.0/24'}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))

+ 2 - 2
netbox/extras/tests/test_tags.py

@@ -35,9 +35,9 @@ class TaggedItemTest(APITestCase):
 
 
         site = Site.objects.create(
         site = Site.objects.create(
             name='Test Site',
             name='Test Site',
-            slug='test-site',
-            tags=['Foo', 'Bar', 'Baz']
+            slug='test-site'
         )
         )
+        site.tags.add('Foo', 'Bar', 'Baz')
 
 
         data = {
         data = {
             'tags': ['Foo', 'Bar', 'New Tag']
             'tags': ['Foo', 'Bar', 'New Tag']

+ 5 - 5
netbox/extras/tests/test_views.py

@@ -6,6 +6,7 @@ from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from dcim.models import Site
 from dcim.models import Site
+from extras.constants import OBJECTCHANGE_ACTION_UPDATE
 from extras.models import ConfigContext, ObjectChange, Tag
 from extras.models import ConfigContext, ObjectChange, Tag
 from utilities.testing import create_test_user
 from utilities.testing import create_test_user
 
 
@@ -82,11 +83,10 @@ class ObjectChangeTestCase(TestCase):
 
 
         # Create three ObjectChanges
         # Create three ObjectChanges
         for i in range(1, 4):
         for i in range(1, 4):
-            site.log_change(
-                user=user,
-                request_id=uuid.uuid4(),
-                action=2
-            )
+            oc = site.to_objectchange(action=OBJECTCHANGE_ACTION_UPDATE)
+            oc.user = user
+            oc.request_id = uuid.uuid4()
+            oc.save()
 
 
     def test_objectchange_list(self):
     def test_objectchange_list(self):
 
 

+ 8 - 3
netbox/extras/urls.py

@@ -9,6 +9,7 @@ urlpatterns = [
 
 
     # Tags
     # Tags
     path(r'tags/', views.TagListView.as_view(), name='tag_list'),
     path(r'tags/', views.TagListView.as_view(), name='tag_list'),
+    path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
     path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
     path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
     path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
     path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
@@ -28,13 +29,17 @@ urlpatterns = [
     path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
     path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
 
 
+    # Change logging
+    path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
+    path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
+
     # Reports
     # Reports
     path(r'reports/', views.ReportListView.as_view(), name='report_list'),
     path(r'reports/', views.ReportListView.as_view(), name='report_list'),
     path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
     path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
     path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
     path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
 
 
-    # Change logging
-    path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
-    path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
+    # Scripts
+    path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
+    path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
 
 
 ]
 ]

+ 85 - 18
netbox/extras/views.py

@@ -4,7 +4,7 @@ from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, Q
 from django.db.models import Count, Q
-from django.http import Http404
+from django.http import Http404, HttpResponseForbidden
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
@@ -13,13 +13,10 @@ from django_tables2 import RequestConfig
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
 from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
 from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
-from . import filters
-from .forms import (
-    ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
-    TagFilterForm, TagForm,
-)
+from . import filters, forms
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
+from .scripts import get_scripts, run_script
 from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 
 
 
 
@@ -35,7 +32,7 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
         'name'
         'name'
     )
     )
     filter = filters.TagFilter
     filter = filters.TagFilter
-    filter_form = TagFilterForm
+    filter_form = forms.TagFilterForm
     table = TagTable
     table = TagTable
     template_name = 'extras/tag_list.html'
     template_name = 'extras/tag_list.html'
 
 
@@ -47,10 +44,8 @@ class TagView(View):
         tag = get_object_or_404(Tag, slug=slug)
         tag = get_object_or_404(Tag, slug=slug)
         tagged_items = TaggedItem.objects.filter(
         tagged_items = TaggedItem.objects.filter(
             tag=tag
             tag=tag
-        ).select_related(
-            'content_type'
         ).prefetch_related(
         ).prefetch_related(
-            'content_object'
+            'content_type', 'content_object'
         )
         )
 
 
         # Generate a table of all items tagged with this Tag
         # Generate a table of all items tagged with this Tag
@@ -71,7 +66,7 @@ class TagView(View):
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'extras.change_tag'
     permission_required = 'extras.change_tag'
     model = Tag
     model = Tag
-    model_form = TagForm
+    model_form = forms.TagForm
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
     template_name = 'extras/tag_edit.html'
     template_name = 'extras/tag_edit.html'
 
 
@@ -82,6 +77,19 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
 
 
+class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'extras.change_tag'
+    queryset = Tag.objects.annotate(
+        items=Count('extras_taggeditem_items', distinct=True)
+    ).order_by(
+        'name'
+    )
+    # filter = filters.ProviderFilter
+    table = TagTable
+    form = forms.TagBulkEditForm
+    default_return_url = 'circuits:provider_list'
+
+
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'extras.delete_tag'
     permission_required = 'extras.delete_tag'
     queryset = Tag.objects.annotate(
     queryset = Tag.objects.annotate(
@@ -101,7 +109,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'extras.view_configcontext'
     permission_required = 'extras.view_configcontext'
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filter = filters.ConfigContextFilter
     filter = filters.ConfigContextFilter
-    filter_form = ConfigContextFilterForm
+    filter_form = forms.ConfigContextFilterForm
     table = ConfigContextTable
     table = ConfigContextTable
     template_name = 'extras/configcontext_list.html'
     template_name = 'extras/configcontext_list.html'
 
 
@@ -121,7 +129,7 @@ class ConfigContextView(PermissionRequiredMixin, View):
 class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
 class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'extras.add_configcontext'
     permission_required = 'extras.add_configcontext'
     model = ConfigContext
     model = ConfigContext
-    model_form = ConfigContextForm
+    model_form = forms.ConfigContextForm
     default_return_url = 'extras:configcontext_list'
     default_return_url = 'extras:configcontext_list'
     template_name = 'extras/configcontext_edit.html'
     template_name = 'extras/configcontext_edit.html'
 
 
@@ -135,7 +143,7 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filter = filters.ConfigContextFilter
     filter = filters.ConfigContextFilter
     table = ConfigContextTable
     table = ConfigContextTable
-    form = ConfigContextBulkEditForm
+    form = forms.ConfigContextBulkEditForm
     default_return_url = 'extras:configcontext_list'
     default_return_url = 'extras:configcontext_list'
 
 
 
 
@@ -178,9 +186,9 @@ class ObjectConfigContextView(View):
 
 
 class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
 class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'extras.view_objectchange'
     permission_required = 'extras.view_objectchange'
-    queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
+    queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
     filter = filters.ObjectChangeFilter
     filter = filters.ObjectChangeFilter
-    filter_form = ObjectChangeFilterForm
+    filter_form = forms.ObjectChangeFilterForm
     table = ObjectChangeTable
     table = ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
 
 
@@ -217,7 +225,7 @@ class ObjectChangeLogView(View):
 
 
         # Gather all changes for this object (and its related objects)
         # Gather all changes for this object (and its related objects)
         content_type = ContentType.objects.get_for_model(model)
         content_type = ContentType.objects.get_for_model(model)
-        objectchanges = ObjectChange.objects.select_related(
+        objectchanges = ObjectChange.objects.prefetch_related(
             'user', 'changed_object_type'
             'user', 'changed_object_type'
         ).filter(
         ).filter(
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
             Q(changed_object_type=content_type, changed_object_id=obj.pk) |
@@ -259,7 +267,7 @@ class ObjectChangeLogView(View):
 class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
 class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'extras.change_imageattachment'
     permission_required = 'extras.change_imageattachment'
     model = ImageAttachment
     model = ImageAttachment
-    model_form = ImageAttachmentForm
+    model_form = forms.ImageAttachmentForm
 
 
     def alter_obj(self, imageattachment, request, args, kwargs):
     def alter_obj(self, imageattachment, request, args, kwargs):
         if not imageattachment.pk:
         if not imageattachment.pk:
@@ -355,3 +363,62 @@ class ReportRunView(PermissionRequiredMixin, View):
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
 
 
         return redirect('extras:report', name=report.full_name)
         return redirect('extras:report', name=report.full_name)
+
+
+#
+# Scripts
+#
+
+class ScriptListView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_script'
+
+    def get(self, request):
+
+        return render(request, 'extras/script_list.html', {
+            'scripts': get_scripts(),
+        })
+
+
+class ScriptView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_script'
+
+    def _get_script(self, module, name):
+        scripts = get_scripts()
+        try:
+            return scripts[module][name]()
+        except KeyError:
+            raise Http404
+
+    def get(self, request, module, name):
+
+        script = self._get_script(module, name)
+        form = script.as_form()
+
+        return render(request, 'extras/script.html', {
+            'module': module,
+            'script': script,
+            'form': form,
+        })
+
+    def post(self, request, module, name):
+
+        # Permissions check
+        if not request.user.has_perm('extras.run_script'):
+            return HttpResponseForbidden()
+
+        script = self._get_script(module, name)
+        form = script.as_form(request.POST, request.FILES)
+        output = None
+        execution_time = None
+
+        if form.is_valid():
+            commit = form.cleaned_data.pop('_commit')
+            output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
+
+        return render(request, 'extras/script.html', {
+            'module': module,
+            'script': script,
+            'form': form,
+            'output': output,
+            'execution_time': execution_time,
+        })

+ 9 - 16
netbox/ipam/api/views.py

@@ -33,7 +33,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
 #
 #
 
 
 class VRFViewSet(CustomFieldModelViewSet):
 class VRFViewSet(CustomFieldModelViewSet):
-    queryset = VRF.objects.select_related('tenant').prefetch_related('tags').annotate(
+    queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate(
         ipaddress_count=get_subquery(IPAddress, 'vrf'),
         ipaddress_count=get_subquery(IPAddress, 'vrf'),
         prefix_count=get_subquery(Prefix, 'vrf')
         prefix_count=get_subquery(Prefix, 'vrf')
     )
     )
@@ -58,7 +58,7 @@ class RIRViewSet(ModelViewSet):
 #
 #
 
 
 class AggregateViewSet(CustomFieldModelViewSet):
 class AggregateViewSet(CustomFieldModelViewSet):
-    queryset = Aggregate.objects.select_related('rir').prefetch_related('tags')
+    queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
     serializer_class = serializers.AggregateSerializer
     serializer_class = serializers.AggregateSerializer
     filterset_class = filters.AggregateFilter
     filterset_class = filters.AggregateFilter
 
 
@@ -81,11 +81,7 @@ class RoleViewSet(ModelViewSet):
 #
 #
 
 
 class PrefixViewSet(CustomFieldModelViewSet):
 class PrefixViewSet(CustomFieldModelViewSet):
-    queryset = Prefix.objects.select_related(
-        'site', 'vrf__tenant', 'tenant', 'vlan', 'role'
-    ).prefetch_related(
-        'tags'
-    )
+    queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags')
     serializer_class = serializers.PrefixSerializer
     serializer_class = serializers.PrefixSerializer
     filterset_class = filters.PrefixFilter
     filterset_class = filters.PrefixFilter
 
 
@@ -263,9 +259,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class IPAddressViewSet(CustomFieldModelViewSet):
 class IPAddressViewSet(CustomFieldModelViewSet):
-    queryset = IPAddress.objects.select_related(
-        'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
-    ).prefetch_related(
+    queryset = IPAddress.objects.prefetch_related(
+        'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine',
         'nat_outside', 'tags',
         'nat_outside', 'tags',
     )
     )
     serializer_class = serializers.IPAddressSerializer
     serializer_class = serializers.IPAddressSerializer
@@ -277,7 +272,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class VLANGroupViewSet(ModelViewSet):
 class VLANGroupViewSet(ModelViewSet):
-    queryset = VLANGroup.objects.select_related('site').annotate(
+    queryset = VLANGroup.objects.prefetch_related('site').annotate(
         vlan_count=Count('vlans')
         vlan_count=Count('vlans')
     )
     )
     serializer_class = serializers.VLANGroupSerializer
     serializer_class = serializers.VLANGroupSerializer
@@ -289,10 +284,8 @@ class VLANGroupViewSet(ModelViewSet):
 #
 #
 
 
 class VLANViewSet(CustomFieldModelViewSet):
 class VLANViewSet(CustomFieldModelViewSet):
-    queryset = VLAN.objects.select_related(
-        'site', 'group', 'tenant', 'role'
-    ).prefetch_related(
-        'tags'
+    queryset = VLAN.objects.prefetch_related(
+        'site', 'group', 'tenant', 'role', 'tags'
     ).annotate(
     ).annotate(
         prefix_count=get_subquery(Prefix, 'role')
         prefix_count=get_subquery(Prefix, 'role')
     )
     )
@@ -305,6 +298,6 @@ class VLANViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class ServiceViewSet(ModelViewSet):
 class ServiceViewSet(ModelViewSet):
-    queryset = Service.objects.select_related('device').prefetch_related('tags')
+    queryset = Service.objects.prefetch_related('device').prefetch_related('tags')
     serializer_class = serializers.ServiceSerializer
     serializer_class = serializers.ServiceSerializer
     filterset_class = filters.ServiceFilter
     filterset_class = filters.ServiceFilter

+ 1 - 1
netbox/ipam/filters.py

@@ -360,7 +360,7 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
         try:
         try:
-            device = Device.objects.select_related('device_type').get(**{name: value})
+            device = Device.objects.prefetch_related('device_type').get(**{name: value})
             vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
             vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
             return queryset.filter(interface_id__in=vc_interface_ids)
             return queryset.filter(interface_id__in=vc_interface_ids)
         except Device.DoesNotExist:
         except Device.DoesNotExist:

+ 6 - 12
netbox/ipam/models.py

@@ -647,26 +647,20 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
-    def log_change(self, user, request_id, action):
-        """
-        Include the connected Interface (if any).
-        """
-
-        # It's possible that an IPAddress can be deleted _after_ its parent Interface, in which case trying to resolve
-        # the interface will raise DoesNotExist.
+    def to_objectchange(self, action):
+        # Annotate the assigned Interface (if any)
         try:
         try:
             parent_obj = self.interface
             parent_obj = self.interface
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             parent_obj = None
             parent_obj = None
 
 
-        ObjectChange(
-            user=user,
-            request_id=request_id,
+        return ObjectChange(
             changed_object=self,
             changed_object=self,
-            related_object=parent_obj,
+            object_repr=str(self),
             action=action,
             action=action,
+            related_object=parent_obj,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
-        ).save()
+        )
 
 
     def to_csv(self):
     def to_csv(self):
 
 

+ 34 - 38
netbox/ipam/views.py

@@ -115,7 +115,7 @@ def add_available_vlans(vlan_group, vlans):
 
 
 class VRFListView(PermissionRequiredMixin, ObjectListView):
 class VRFListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_vrf'
     permission_required = 'ipam.view_vrf'
-    queryset = VRF.objects.select_related('tenant')
+    queryset = VRF.objects.prefetch_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
     filter_form = forms.VRFFilterForm
     filter_form = forms.VRFFilterForm
     table = tables.VRFTable
     table = tables.VRFTable
@@ -163,7 +163,7 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
 class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vrf'
     permission_required = 'ipam.change_vrf'
-    queryset = VRF.objects.select_related('tenant')
+    queryset = VRF.objects.prefetch_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
     table = tables.VRFTable
     table = tables.VRFTable
     form = forms.VRFBulkEditForm
     form = forms.VRFBulkEditForm
@@ -172,7 +172,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vrf'
     permission_required = 'ipam.delete_vrf'
-    queryset = VRF.objects.select_related('tenant')
+    queryset = VRF.objects.prefetch_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
     table = tables.VRFTable
     table = tables.VRFTable
     default_return_url = 'ipam:vrf_list'
     default_return_url = 'ipam:vrf_list'
@@ -291,7 +291,7 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class AggregateListView(PermissionRequiredMixin, ObjectListView):
 class AggregateListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_aggregate'
     permission_required = 'ipam.view_aggregate'
-    queryset = Aggregate.objects.select_related('rir').extra(select={
+    queryset = Aggregate.objects.prefetch_related('rir').extra(select={
         'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
         'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
     })
     })
     filter = filters.AggregateFilter
     filter = filters.AggregateFilter
@@ -326,7 +326,7 @@ class AggregateView(PermissionRequiredMixin, View):
         # Find all child prefixes contained by this aggregate
         # Find all child prefixes contained by this aggregate
         child_prefixes = Prefix.objects.filter(
         child_prefixes = Prefix.objects.filter(
             prefix__net_contained_or_equal=str(aggregate.prefix)
             prefix__net_contained_or_equal=str(aggregate.prefix)
-        ).select_related(
+        ).prefetch_related(
             'site', 'role'
             'site', 'role'
         ).annotate_depth(
         ).annotate_depth(
             limit=0
             limit=0
@@ -384,7 +384,7 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_aggregate'
     permission_required = 'ipam.change_aggregate'
-    queryset = Aggregate.objects.select_related('rir')
+    queryset = Aggregate.objects.prefetch_related('rir')
     filter = filters.AggregateFilter
     filter = filters.AggregateFilter
     table = tables.AggregateTable
     table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
     form = forms.AggregateBulkEditForm
@@ -393,7 +393,7 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_aggregate'
     permission_required = 'ipam.delete_aggregate'
-    queryset = Aggregate.objects.select_related('rir')
+    queryset = Aggregate.objects.prefetch_related('rir')
     filter = filters.AggregateFilter
     filter = filters.AggregateFilter
     table = tables.AggregateTable
     table = tables.AggregateTable
     default_return_url = 'ipam:aggregate_list'
     default_return_url = 'ipam:aggregate_list'
@@ -441,7 +441,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class PrefixListView(PermissionRequiredMixin, ObjectListView):
 class PrefixListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_prefix'
     permission_required = 'ipam.view_prefix'
-    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
+    queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     filter_form = forms.PrefixFilterForm
     table = tables.PrefixDetailTable
     table = tables.PrefixDetailTable
@@ -458,7 +458,7 @@ class PrefixView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        prefix = get_object_or_404(Prefix.objects.select_related(
+        prefix = get_object_or_404(Prefix.objects.prefetch_related(
             'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
             'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
         ), pk=pk)
         ), pk=pk)
 
 
@@ -472,7 +472,7 @@ class PrefixView(PermissionRequiredMixin, View):
             Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
             Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
         ).filter(
         ).filter(
             prefix__net_contains=str(prefix.prefix)
             prefix__net_contains=str(prefix.prefix)
-        ).select_related(
+        ).prefetch_related(
             'site', 'role'
             'site', 'role'
         ).annotate_depth()
         ).annotate_depth()
         parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
         parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
@@ -483,7 +483,7 @@ class PrefixView(PermissionRequiredMixin, View):
             vrf=prefix.vrf, prefix=str(prefix.prefix)
             vrf=prefix.vrf, prefix=str(prefix.prefix)
         ).exclude(
         ).exclude(
             pk=prefix.pk
             pk=prefix.pk
-        ).select_related(
+        ).prefetch_related(
             'site', 'role'
             'site', 'role'
         )
         )
         duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
         duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
@@ -505,7 +505,7 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
         prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
         prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
 
 
         # Child prefixes table
         # Child prefixes table
-        child_prefixes = prefix.get_child_prefixes().select_related(
+        child_prefixes = prefix.get_child_prefixes().prefetch_related(
             'site', 'vlan', 'role',
             'site', 'vlan', 'role',
         ).annotate_depth(limit=0)
         ).annotate_depth(limit=0)
 
 
@@ -548,7 +548,7 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
         prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
         prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
 
 
         # Find all IPAddresses belonging to this Prefix
         # Find all IPAddresses belonging to this Prefix
-        ipaddresses = prefix.get_child_ips().select_related(
+        ipaddresses = prefix.get_child_ips().prefetch_related(
             'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
             'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
         )
         )
         ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
         ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
@@ -608,7 +608,7 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_prefix'
     permission_required = 'ipam.change_prefix'
-    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
+    queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
     table = tables.PrefixTable
     table = tables.PrefixTable
     form = forms.PrefixBulkEditForm
     form = forms.PrefixBulkEditForm
@@ -617,7 +617,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_prefix'
     permission_required = 'ipam.delete_prefix'
-    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
+    queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
     table = tables.PrefixTable
     table = tables.PrefixTable
     default_return_url = 'ipam:prefix_list'
     default_return_url = 'ipam:prefix_list'
@@ -629,10 +629,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class IPAddressListView(PermissionRequiredMixin, ObjectListView):
 class IPAddressListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_ipaddress'
     permission_required = 'ipam.view_ipaddress'
-    queryset = IPAddress.objects.select_related(
-        'vrf__tenant', 'tenant', 'nat_inside'
-    ).prefetch_related(
-        'interface__device', 'interface__virtual_machine'
+    queryset = IPAddress.objects.prefetch_related(
+        'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine'
     )
     )
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     filter_form = forms.IPAddressFilterForm
@@ -645,12 +643,12 @@ class IPAddressView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk)
+        ipaddress = get_object_or_404(IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), pk=pk)
 
 
         # Parent prefixes table
         # Parent prefixes table
         parent_prefixes = Prefix.objects.filter(
         parent_prefixes = Prefix.objects.filter(
             vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
             vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
-        ).select_related(
+        ).prefetch_related(
             'site', 'role'
             'site', 'role'
         )
         )
         parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
         parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
@@ -661,10 +659,8 @@ class IPAddressView(PermissionRequiredMixin, View):
             vrf=ipaddress.vrf, address=str(ipaddress.address)
             vrf=ipaddress.vrf, address=str(ipaddress.address)
         ).exclude(
         ).exclude(
             pk=ipaddress.pk
             pk=ipaddress.pk
-        ).select_related(
-            'nat_inside'
         ).prefetch_related(
         ).prefetch_related(
-            'interface__device'
+            'nat_inside', 'interface__device'
         )
         )
         # Exclude anycast IPs if this IP is anycast
         # Exclude anycast IPs if this IP is anycast
         if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
         if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
@@ -742,7 +738,7 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
 
 
         if form.is_valid():
         if form.is_valid():
 
 
-            queryset = IPAddress.objects.select_related(
+            queryset = IPAddress.objects.prefetch_related(
                 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
                 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
             ).filter(
             ).filter(
                 vrf=form.cleaned_data['vrf'],
                 vrf=form.cleaned_data['vrf'],
@@ -781,7 +777,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
 class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_ipaddress'
     permission_required = 'ipam.change_ipaddress'
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
     form = forms.IPAddressBulkEditForm
@@ -790,7 +786,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_ipaddress'
     permission_required = 'ipam.delete_ipaddress'
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
@@ -802,7 +798,7 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
 class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_vlangroup'
     permission_required = 'ipam.view_vlangroup'
-    queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
+    queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
     filter = filters.VLANGroupFilter
     filter = filters.VLANGroupFilter
     filter_form = forms.VLANGroupFilterForm
     filter_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
@@ -829,7 +825,7 @@ class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlangroup'
     permission_required = 'ipam.delete_vlangroup'
-    queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
+    queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
     filter = filters.VLANGroupFilter
     filter = filters.VLANGroupFilter
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
     default_return_url = 'ipam:vlangroup_list'
     default_return_url = 'ipam:vlangroup_list'
@@ -878,7 +874,7 @@ class VLANGroupVLANsView(PermissionRequiredMixin, View):
 
 
 class VLANListView(PermissionRequiredMixin, ObjectListView):
 class VLANListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_vlan'
     permission_required = 'ipam.view_vlan'
-    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
+    queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     filter_form = forms.VLANFilterForm
     table = tables.VLANDetailTable
     table = tables.VLANDetailTable
@@ -890,10 +886,10 @@ class VLANView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        vlan = get_object_or_404(VLAN.objects.select_related(
+        vlan = get_object_or_404(VLAN.objects.prefetch_related(
             'site__region', 'tenant__group', 'role'
             'site__region', 'tenant__group', 'role'
         ), pk=pk)
         ), pk=pk)
-        prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
+        prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role')
         prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
         prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
         prefix_table.exclude = ('vlan',)
         prefix_table.exclude = ('vlan',)
 
 
@@ -909,7 +905,7 @@ class VLANMembersView(PermissionRequiredMixin, View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
         vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
-        members = vlan.get_members().select_related('device', 'virtual_machine')
+        members = vlan.get_members().prefetch_related('device', 'virtual_machine')
 
 
         members_table = tables.VLANMemberTable(members)
         members_table = tables.VLANMemberTable(members)
 
 
@@ -953,7 +949,7 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
 class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vlan'
     permission_required = 'ipam.change_vlan'
-    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
+    queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
     table = tables.VLANTable
     table = tables.VLANTable
     form = forms.VLANBulkEditForm
     form = forms.VLANBulkEditForm
@@ -962,7 +958,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlan'
     permission_required = 'ipam.delete_vlan'
-    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
+    queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
     table = tables.VLANTable
     table = tables.VLANTable
     default_return_url = 'ipam:vlan_list'
     default_return_url = 'ipam:vlan_list'
@@ -974,7 +970,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class ServiceListView(PermissionRequiredMixin, ObjectListView):
 class ServiceListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_service'
     permission_required = 'ipam.view_service'
-    queryset = Service.objects.select_related('device', 'virtual_machine')
+    queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filter = filters.ServiceFilter
     filter = filters.ServiceFilter
     filter_form = forms.ServiceFilterForm
     filter_form = forms.ServiceFilterForm
     table = tables.ServiceTable
     table = tables.ServiceTable
@@ -1021,7 +1017,7 @@ class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_service'
     permission_required = 'ipam.change_service'
-    queryset = Service.objects.select_related('device', 'virtual_machine')
+    queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filter = filters.ServiceFilter
     filter = filters.ServiceFilter
     table = tables.ServiceTable
     table = tables.ServiceTable
     form = forms.ServiceBulkEditForm
     form = forms.ServiceBulkEditForm
@@ -1030,7 +1026,7 @@ class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_service'
     permission_required = 'ipam.delete_service'
-    queryset = Service.objects.select_related('device', 'virtual_machine')
+    queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filter = filters.ServiceFilter
     filter = filters.ServiceFilter
     table = tables.ServiceTable
     table = tables.ServiceTable
     default_return_url = 'ipam:service_list'
     default_return_url = 'ipam:service_list'

+ 1 - 1
netbox/netbox/api.py

@@ -37,7 +37,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
     def authenticate_credentials(self, key):
     def authenticate_credentials(self, key):
         model = self.get_model()
         model = self.get_model()
         try:
         try:
-            token = model.objects.select_related('user').get(key=key)
+            token = model.objects.prefetch_related('user').get(key=key)
         except model.DoesNotExist:
         except model.DoesNotExist:
             raise exceptions.AuthenticationFailed("Invalid token")
             raise exceptions.AuthenticationFailed("Invalid token")
 
 

+ 2 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.6.2'
+VERSION = '2.6.3'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -85,6 +85,7 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
+SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')

+ 0 - 1
netbox/netbox/urls.py

@@ -15,7 +15,6 @@ schema_view = get_schema_view(
         default_version='v2',
         default_version='v2',
         description="API to access NetBox",
         description="API to access NetBox",
         terms_of_service="https://github.com/netbox-community/netbox",
         terms_of_service="https://github.com/netbox-community/netbox",
-        contact=openapi.Contact(email="netbox@digitalocean.com"),
         license=openapi.License(name="Apache v2 License"),
         license=openapi.License(name="Apache v2 License"),
     ),
     ),
     validators=['flex', 'ssv'],
     validators=['flex', 'ssv'],

+ 17 - 17
netbox/netbox/views.py

@@ -46,38 +46,38 @@ SEARCH_TYPES = OrderedDict((
         'url': 'circuits:provider_list',
         'url': 'circuits:provider_list',
     }),
     }),
     ('circuit', {
     ('circuit', {
-        'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
+        'queryset': Circuit.objects.prefetch_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
         'filter': CircuitFilter,
         'filter': CircuitFilter,
         'table': CircuitTable,
         'table': CircuitTable,
         'url': 'circuits:circuit_list',
         'url': 'circuits:circuit_list',
     }),
     }),
     # DCIM
     # DCIM
     ('site', {
     ('site', {
-        'queryset': Site.objects.select_related('region', 'tenant'),
+        'queryset': Site.objects.prefetch_related('region', 'tenant'),
         'filter': SiteFilter,
         'filter': SiteFilter,
         'table': SiteTable,
         'table': SiteTable,
         'url': 'dcim:site_list',
         'url': 'dcim:site_list',
     }),
     }),
     ('rack', {
     ('rack', {
-        'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
+        'queryset': Rack.objects.prefetch_related('site', 'group', 'tenant', 'role'),
         'filter': RackFilter,
         'filter': RackFilter,
         'table': RackTable,
         'table': RackTable,
         'url': 'dcim:rack_list',
         'url': 'dcim:rack_list',
     }),
     }),
     ('rackgroup', {
     ('rackgroup', {
-        'queryset': RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')),
+        'queryset': RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')),
         'filter': RackGroupFilter,
         'filter': RackGroupFilter,
         'table': RackGroupTable,
         'table': RackGroupTable,
         'url': 'dcim:rackgroup_list',
         'url': 'dcim:rackgroup_list',
     }),
     }),
     ('devicetype', {
     ('devicetype', {
-        'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')),
+        'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')),
         'filter': DeviceTypeFilter,
         'filter': DeviceTypeFilter,
         'table': DeviceTypeTable,
         'table': DeviceTypeTable,
         'url': 'dcim:devicetype_list',
         'url': 'dcim:devicetype_list',
     }),
     }),
     ('device', {
     ('device', {
-        'queryset': Device.objects.select_related(
+        'queryset': Device.objects.prefetch_related(
             'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
             'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
         ),
         ),
         'filter': DeviceFilter,
         'filter': DeviceFilter,
@@ -85,7 +85,7 @@ SEARCH_TYPES = OrderedDict((
         'url': 'dcim:device_list',
         'url': 'dcim:device_list',
     }),
     }),
     ('virtualchassis', {
     ('virtualchassis', {
-        'queryset': VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')),
+        'queryset': VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')),
         'filter': VirtualChassisFilter,
         'filter': VirtualChassisFilter,
         'table': VirtualChassisTable,
         'table': VirtualChassisTable,
         'url': 'dcim:virtualchassis_list',
         'url': 'dcim:virtualchassis_list',
@@ -104,58 +104,58 @@ SEARCH_TYPES = OrderedDict((
     }),
     }),
     # IPAM
     # IPAM
     ('vrf', {
     ('vrf', {
-        'queryset': VRF.objects.select_related('tenant'),
+        'queryset': VRF.objects.prefetch_related('tenant'),
         'filter': VRFFilter,
         'filter': VRFFilter,
         'table': VRFTable,
         'table': VRFTable,
         'url': 'ipam:vrf_list',
         'url': 'ipam:vrf_list',
     }),
     }),
     ('aggregate', {
     ('aggregate', {
-        'queryset': Aggregate.objects.select_related('rir'),
+        'queryset': Aggregate.objects.prefetch_related('rir'),
         'filter': AggregateFilter,
         'filter': AggregateFilter,
         'table': AggregateTable,
         'table': AggregateTable,
         'url': 'ipam:aggregate_list',
         'url': 'ipam:aggregate_list',
     }),
     }),
     ('prefix', {
     ('prefix', {
-        'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
+        'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
         'filter': PrefixFilter,
         'filter': PrefixFilter,
         'table': PrefixTable,
         'table': PrefixTable,
         'url': 'ipam:prefix_list',
         'url': 'ipam:prefix_list',
     }),
     }),
     ('ipaddress', {
     ('ipaddress', {
-        'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'),
+        'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
         'filter': IPAddressFilter,
         'filter': IPAddressFilter,
         'table': IPAddressTable,
         'table': IPAddressTable,
         'url': 'ipam:ipaddress_list',
         'url': 'ipam:ipaddress_list',
     }),
     }),
     ('vlan', {
     ('vlan', {
-        'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
+        'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
         'filter': VLANFilter,
         'filter': VLANFilter,
         'table': VLANTable,
         'table': VLANTable,
         'url': 'ipam:vlan_list',
         'url': 'ipam:vlan_list',
     }),
     }),
     # Secrets
     # Secrets
     ('secret', {
     ('secret', {
-        'queryset': Secret.objects.select_related('role', 'device'),
+        'queryset': Secret.objects.prefetch_related('role', 'device'),
         'filter': SecretFilter,
         'filter': SecretFilter,
         'table': SecretTable,
         'table': SecretTable,
         'url': 'secrets:secret_list',
         'url': 'secrets:secret_list',
     }),
     }),
     # Tenancy
     # Tenancy
     ('tenant', {
     ('tenant', {
-        'queryset': Tenant.objects.select_related('group'),
+        'queryset': Tenant.objects.prefetch_related('group'),
         'filter': TenantFilter,
         'filter': TenantFilter,
         'table': TenantTable,
         'table': TenantTable,
         'url': 'tenancy:tenant_list',
         'url': 'tenancy:tenant_list',
     }),
     }),
     # Virtualization
     # Virtualization
     ('cluster', {
     ('cluster', {
-        'queryset': Cluster.objects.select_related('type', 'group'),
+        'queryset': Cluster.objects.prefetch_related('type', 'group'),
         'filter': ClusterFilter,
         'filter': ClusterFilter,
         'table': ClusterTable,
         'table': ClusterTable,
         'url': 'virtualization:cluster_list',
         'url': 'virtualization:cluster_list',
     }),
     }),
     ('virtualmachine', {
     ('virtualmachine', {
-        'queryset': VirtualMachine.objects.select_related(
+        'queryset': VirtualMachine.objects.prefetch_related(
             'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
             'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
         ),
         ),
         'filter': VirtualMachineFilter,
         'filter': VirtualMachineFilter,
@@ -224,7 +224,7 @@ class HomeView(View):
             'stats': stats,
             'stats': stats,
             'topology_maps': TopologyMap.objects.filter(site__isnull=True),
             'topology_maps': TopologyMap.objects.filter(site__isnull=True),
             'report_results': ReportResult.objects.order_by('-created')[:10],
             'report_results': ReportResult.objects.order_by('-created')[:10],
-            'changelog': ObjectChange.objects.select_related('user', 'changed_object_type')[:50]
+            'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:50]
         })
         })
 
 
 
 

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.css.map


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.min.css.map


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.css.map


Разлика између датотеке није приказан због своје велике величине
+ 0 - 4
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css.map


Разлика између датотеке није приказан због своје велике величине
+ 0 - 5
netbox/project-static/bootstrap-3.3.7-dist/js/bootstrap.min.js


+ 100 - 100
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.css → netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css

@@ -1,6 +1,6 @@
 /*!
 /*!
- * Bootstrap v3.3.7 (http://getbootstrap.com)
- * Copyright 2011-2016 Twitter, Inc.
+ * Bootstrap v3.4.1 (https://getbootstrap.com/)
+ * Copyright 2011-2019 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,
@@ -9,9 +9,9 @@
 .btn-info,
 .btn-info,
 .btn-warning,
 .btn-warning,
 .btn-danger {
 .btn-danger {
-  text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
-  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
-          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);
 }
 }
 .btn-default:active,
 .btn-default:active,
 .btn-primary:active,
 .btn-primary:active,
@@ -25,8 +25,8 @@
 .btn-info.active,
 .btn-info.active,
 .btn-warning.active,
 .btn-warning.active,
 .btn-danger.active {
 .btn-danger.active {
-  -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
-          box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
+  -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+  box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
 }
 }
 .btn-default.disabled,
 .btn-default.disabled,
 .btn-primary.disabled,
 .btn-primary.disabled,
@@ -47,7 +47,7 @@ fieldset[disabled] .btn-info,
 fieldset[disabled] .btn-warning,
 fieldset[disabled] .btn-warning,
 fieldset[disabled] .btn-danger {
 fieldset[disabled] .btn-danger {
   -webkit-box-shadow: none;
   -webkit-box-shadow: none;
-          box-shadow: none;
+  box-shadow: none;
 }
 }
 .btn-default .badge,
 .btn-default .badge,
 .btn-primary .badge,
 .btn-primary .badge,
@@ -62,15 +62,15 @@ fieldset[disabled] .btn-danger {
   background-image: none;
   background-image: none;
 }
 }
 .btn-default {
 .btn-default {
-  text-shadow: 0 1px 0 #fff;
   background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
   background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
-  background-image:      -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
+  background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
-  background-image:         linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
+  background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
   border-color: #dbdbdb;
   border-color: #dbdbdb;
+  text-shadow: 0 1px 0 #fff;
   border-color: #ccc;
   border-color: #ccc;
 }
 }
 .btn-default:hover,
 .btn-default:hover,
@@ -106,9 +106,9 @@ fieldset[disabled] .btn-default.active {
 }
 }
 .btn-primary {
 .btn-primary {
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
-  background-image:      -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
-  background-image:         linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
+  background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
@@ -147,9 +147,9 @@ fieldset[disabled] .btn-primary.active {
 }
 }
 .btn-success {
 .btn-success {
   background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
   background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
-  background-image:      -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
+  background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
-  background-image:         linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
+  background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
@@ -188,9 +188,9 @@ fieldset[disabled] .btn-success.active {
 }
 }
 .btn-info {
 .btn-info {
   background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
   background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
-  background-image:      -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
+  background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
-  background-image:         linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
+  background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
@@ -229,9 +229,9 @@ fieldset[disabled] .btn-info.active {
 }
 }
 .btn-warning {
 .btn-warning {
   background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
   background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
-  background-image:      -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
+  background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
-  background-image:         linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
+  background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
@@ -270,9 +270,9 @@ fieldset[disabled] .btn-warning.active {
 }
 }
 .btn-danger {
 .btn-danger {
   background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
   background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
-  background-image:      -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
+  background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
-  background-image:         linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
+  background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
@@ -311,81 +311,81 @@ fieldset[disabled] .btn-danger.active {
 }
 }
 .thumbnail,
 .thumbnail,
 .img-thumbnail {
 .img-thumbnail {
-  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
-          box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
 }
 }
 .dropdown-menu > li > a:hover,
 .dropdown-menu > li > a:hover,
 .dropdown-menu > li > a:focus {
 .dropdown-menu > li > a:focus {
-  background-color: #e8e8e8;
   background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
   background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
-  background-image:      -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
-  background-image:         linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
+  background-color: #e8e8e8;
 }
 }
 .dropdown-menu > .active > a,
 .dropdown-menu > .active > a,
 .dropdown-menu > .active > a:hover,
 .dropdown-menu > .active > a:hover,
 .dropdown-menu > .active > a:focus {
 .dropdown-menu > .active > a:focus {
-  background-color: #2e6da4;
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
-  background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
-  background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
+  background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
+  background-color: #2e6da4;
 }
 }
 .navbar-default {
 .navbar-default {
-  background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
-  background-image:      -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
-  background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
-  background-image:         linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
+  background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);
+  background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#ffffff), to(#f8f8f8));
+  background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
-  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   border-radius: 4px;
   border-radius: 4px;
-  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
-          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);
 }
 }
 .navbar-default .navbar-nav > .open > a,
 .navbar-default .navbar-nav > .open > a,
 .navbar-default .navbar-nav > .active > a {
 .navbar-default .navbar-nav > .active > a {
   background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
   background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
-  background-image:      -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
+  background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
-  background-image:         linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
+  background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
-  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
-          box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
+  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);
+  box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);
 }
 }
 .navbar-brand,
 .navbar-brand,
 .navbar-nav > li > a {
 .navbar-nav > li > a {
-  text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
+  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
 }
 }
 .navbar-inverse {
 .navbar-inverse {
   background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
   background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
-  background-image:      -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
+  background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
-  background-image:         linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
+  background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
-  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
   border-radius: 4px;
   border-radius: 4px;
 }
 }
 .navbar-inverse .navbar-nav > .open > a,
 .navbar-inverse .navbar-nav > .open > a,
 .navbar-inverse .navbar-nav > .active > a {
 .navbar-inverse .navbar-nav > .active > a {
   background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
   background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
-  background-image:      -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
+  background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
-  background-image:         linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
+  background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
-  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
-          box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
+  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);
+  box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);
 }
 }
 .navbar-inverse .navbar-brand,
 .navbar-inverse .navbar-brand,
 .navbar-inverse .navbar-nav > li > a {
 .navbar-inverse .navbar-nav > li > a {
-  text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
 }
 }
 .navbar-static-top,
 .navbar-static-top,
 .navbar-fixed-top,
 .navbar-fixed-top,
@@ -398,120 +398,120 @@ fieldset[disabled] .btn-danger.active {
   .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
   .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
     color: #fff;
     color: #fff;
     background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
     background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
-    background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+    background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
     background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
     background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
-    background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
+    background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
     filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
     filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
     background-repeat: repeat-x;
     background-repeat: repeat-x;
   }
   }
 }
 }
 .alert {
 .alert {
-  text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
-  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
-          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
+  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);
 }
 }
 .alert-success {
 .alert-success {
   background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
   background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
-  background-image:      -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
+  background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
-  background-image:         linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
+  background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
   border-color: #b2dba1;
   border-color: #b2dba1;
 }
 }
 .alert-info {
 .alert-info {
   background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
   background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
-  background-image:      -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
+  background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
-  background-image:         linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
+  background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
   border-color: #9acfea;
   border-color: #9acfea;
 }
 }
 .alert-warning {
 .alert-warning {
   background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
   background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
-  background-image:      -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
+  background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
-  background-image:         linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
+  background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
   border-color: #f5e79e;
   border-color: #f5e79e;
 }
 }
 .alert-danger {
 .alert-danger {
   background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
   background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
-  background-image:      -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
+  background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
-  background-image:         linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
+  background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
   border-color: #dca7a7;
   border-color: #dca7a7;
 }
 }
 .progress {
 .progress {
   background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
   background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
-  background-image:      -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
+  background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
-  background-image:         linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
+  background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .progress-bar {
 .progress-bar {
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
-  background-image:      -o-linear-gradient(top, #337ab7 0%, #286090 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
-  background-image:         linear-gradient(to bottom, #337ab7 0%, #286090 100%);
+  background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .progress-bar-success {
 .progress-bar-success {
   background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
   background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
-  background-image:      -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
+  background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
-  background-image:         linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
+  background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .progress-bar-info {
 .progress-bar-info {
   background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
   background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
-  background-image:      -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
+  background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
-  background-image:         linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
+  background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .progress-bar-warning {
 .progress-bar-warning {
   background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
   background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
-  background-image:      -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
+  background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
-  background-image:         linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
+  background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .progress-bar-danger {
 .progress-bar-danger {
   background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
   background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
-  background-image:      -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
+  background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
-  background-image:         linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
+  background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .progress-bar-striped {
 .progress-bar-striped {
-  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
-  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
-  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+  background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+  background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
 }
 }
 .list-group {
 .list-group {
   border-radius: 4px;
   border-radius: 4px;
-  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
-          box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
 }
 }
 .list-group-item.active,
 .list-group-item.active,
 .list-group-item.active:hover,
 .list-group-item.active:hover,
 .list-group-item.active:focus {
 .list-group-item.active:focus {
   text-shadow: 0 -1px 0 #286090;
   text-shadow: 0 -1px 0 #286090;
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
-  background-image:      -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
-  background-image:         linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
+  background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
   border-color: #2b669a;
   border-color: #2b669a;
@@ -522,66 +522,66 @@ fieldset[disabled] .btn-danger.active {
   text-shadow: none;
   text-shadow: none;
 }
 }
 .panel {
 .panel {
-  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
-          box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
 }
 }
 .panel-default > .panel-heading {
 .panel-default > .panel-heading {
   background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
   background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
-  background-image:      -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
-  background-image:         linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .panel-primary > .panel-heading {
 .panel-primary > .panel-heading {
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
   background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
-  background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
-  background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
+  background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .panel-success > .panel-heading {
 .panel-success > .panel-heading {
   background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
   background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
-  background-image:      -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
+  background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
-  background-image:         linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
+  background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .panel-info > .panel-heading {
 .panel-info > .panel-heading {
   background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
   background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
-  background-image:      -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
+  background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
-  background-image:         linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
+  background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .panel-warning > .panel-heading {
 .panel-warning > .panel-heading {
   background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
   background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
-  background-image:      -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
+  background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
-  background-image:         linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
+  background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .panel-danger > .panel-heading {
 .panel-danger > .panel-heading {
   background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
   background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
-  background-image:      -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
+  background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
-  background-image:         linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
+  background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 .well {
 .well {
   background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
   background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
-  background-image:      -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
+  background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
   background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
   background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
-  background-image:         linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
+  background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
   filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
   background-repeat: repeat-x;
   background-repeat: repeat-x;
   border-color: #dcdcdc;
   border-color: #dcdcdc;
-  -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
-          box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
+  -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);
+  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);
 }
 }
-/*# sourceMappingURL=bootstrap-theme.css.map */
+/*# sourceMappingURL=bootstrap-theme.css.map */

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.css.map


Разлика између датотеке није приказан због своје велике величине
+ 2 - 2
netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap-theme.min.css.map


Разлика између датотеке није приказан због своје велике величине
+ 255 - 173
netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.css.map


Разлика између датотеке није приказан због своје велике величине
+ 4 - 0
netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/bootstrap-3.4.1-dist/css/bootstrap.min.css.map


+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.eot → netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.eot


+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.svg → netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.svg


+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.ttf → netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.ttf


+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff → netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff


+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff2 → netbox/project-static/bootstrap-3.4.1-dist/fonts/glyphicons-halflings-regular.woff2


+ 300 - 97
netbox/project-static/bootstrap-3.3.7-dist/js/bootstrap.js → netbox/project-static/bootstrap-3.4.1-dist/js/bootstrap.js

@@ -1,6 +1,6 @@
 /*!
 /*!
- * Bootstrap v3.3.7 (http://getbootstrap.com)
- * Copyright 2011-2016 Twitter, Inc.
+ * Bootstrap v3.4.1 (https://getbootstrap.com/)
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under the MIT license
  * Licensed under the MIT license
  */
  */
 
 
@@ -17,10 +17,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: transition.js v3.3.7
- * http://getbootstrap.com/javascript/#transitions
+ * Bootstrap: transition.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#transitions
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -28,7 +28,7 @@ if (typeof jQuery === 'undefined') {
 +function ($) {
 +function ($) {
   'use strict';
   'use strict';
 
 
-  // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/)
+  // CSS TRANSITION SUPPORT (Shoutout: https://modernizr.com/)
   // ============================================================
   // ============================================================
 
 
   function transitionEnd() {
   function transitionEnd() {
@@ -50,7 +50,7 @@ if (typeof jQuery === 'undefined') {
     return false // explicit for ie8 (  ._.)
     return false // explicit for ie8 (  ._.)
   }
   }
 
 
-  // http://blog.alexmaccaw.com/css-transitions
+  // https://blog.alexmaccaw.com/css-transitions
   $.fn.emulateTransitionEnd = function (duration) {
   $.fn.emulateTransitionEnd = function (duration) {
     var called = false
     var called = false
     var $el = this
     var $el = this
@@ -77,10 +77,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: alert.js v3.3.7
- * http://getbootstrap.com/javascript/#alerts
+ * Bootstrap: alert.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#alerts
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 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.7'
+  Alert.VERSION = '3.4.1'
 
 
   Alert.TRANSITION_DURATION = 150
   Alert.TRANSITION_DURATION = 150
 
 
@@ -109,7 +109,8 @@ if (typeof jQuery === 'undefined') {
       selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
       selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
     }
     }
 
 
-    var $parent = $(selector === '#' ? [] : selector)
+    selector    = selector === '#' ? [] : selector
+    var $parent = $(document).find(selector)
 
 
     if (e) e.preventDefault()
     if (e) e.preventDefault()
 
 
@@ -172,10 +173,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: button.js v3.3.7
- * http://getbootstrap.com/javascript/#buttons
+ * Bootstrap: button.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#buttons
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 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 +193,7 @@ if (typeof jQuery === 'undefined') {
     this.isLoading = false
     this.isLoading = false
   }
   }
 
 
-  Button.VERSION  = '3.3.7'
+  Button.VERSION  = '3.4.1'
 
 
   Button.DEFAULTS = {
   Button.DEFAULTS = {
     loadingText: 'loading...'
     loadingText: 'loading...'
@@ -298,10 +299,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: carousel.js v3.3.7
- * http://getbootstrap.com/javascript/#carousel
+ * Bootstrap: carousel.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#carousel
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -329,7 +330,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.7'
+  Carousel.VERSION  = '3.4.1'
 
 
   Carousel.TRANSITION_DURATION = 600
   Carousel.TRANSITION_DURATION = 600
 
 
@@ -443,7 +444,9 @@ if (typeof jQuery === 'undefined') {
     var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid"
     var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid"
     if ($.support.transition && this.$element.hasClass('slide')) {
     if ($.support.transition && this.$element.hasClass('slide')) {
       $next.addClass(type)
       $next.addClass(type)
-      $next[0].offsetWidth // force reflow
+      if (typeof $next === 'object' && $next.length) {
+        $next[0].offsetWidth // force reflow
+      }
       $active.addClass(direction)
       $active.addClass(direction)
       $next.addClass(direction)
       $next.addClass(direction)
       $active
       $active
@@ -505,10 +508,17 @@ if (typeof jQuery === 'undefined') {
   // =================
   // =================
 
 
   var clickHandler = function (e) {
   var clickHandler = function (e) {
-    var href
     var $this   = $(this)
     var $this   = $(this)
-    var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7
+    var href    = $this.attr('href')
+    if (href) {
+      href = href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
+    }
+
+    var target  = $this.attr('data-target') || href
+    var $target = $(document).find(target)
+
     if (!$target.hasClass('carousel')) return
     if (!$target.hasClass('carousel')) return
+
     var options = $.extend({}, $target.data(), $this.data())
     var options = $.extend({}, $target.data(), $this.data())
     var slideIndex = $this.attr('data-slide-to')
     var slideIndex = $this.attr('data-slide-to')
     if (slideIndex) options.interval = false
     if (slideIndex) options.interval = false
@@ -536,10 +546,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: collapse.js v3.3.7
- * http://getbootstrap.com/javascript/#collapse
+ * Bootstrap: collapse.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#collapse
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -567,7 +577,7 @@ if (typeof jQuery === 'undefined') {
     if (this.options.toggle) this.toggle()
     if (this.options.toggle) this.toggle()
   }
   }
 
 
-  Collapse.VERSION  = '3.3.7'
+  Collapse.VERSION  = '3.4.1'
 
 
   Collapse.TRANSITION_DURATION = 350
   Collapse.TRANSITION_DURATION = 350
 
 
@@ -674,7 +684,7 @@ if (typeof jQuery === 'undefined') {
   }
   }
 
 
   Collapse.prototype.getParent = function () {
   Collapse.prototype.getParent = function () {
-    return $(this.options.parent)
+    return $(document).find(this.options.parent)
       .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]')
       .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]')
       .each($.proxy(function (i, element) {
       .each($.proxy(function (i, element) {
         var $element = $(element)
         var $element = $(element)
@@ -697,7 +707,7 @@ if (typeof jQuery === 'undefined') {
     var target = $trigger.attr('data-target')
     var target = $trigger.attr('data-target')
       || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
       || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
 
 
-    return $(target)
+    return $(document).find(target)
   }
   }
 
 
 
 
@@ -749,10 +759,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: dropdown.js v3.3.7
- * http://getbootstrap.com/javascript/#dropdowns
+ * Bootstrap: dropdown.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#dropdowns
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -769,7 +779,7 @@ if (typeof jQuery === 'undefined') {
     $(element).on('click.bs.dropdown', this.toggle)
     $(element).on('click.bs.dropdown', this.toggle)
   }
   }
 
 
-  Dropdown.VERSION = '3.3.7'
+  Dropdown.VERSION = '3.4.1'
 
 
   function getParent($this) {
   function getParent($this) {
     var selector = $this.attr('data-target')
     var selector = $this.attr('data-target')
@@ -779,7 +789,7 @@ if (typeof jQuery === 'undefined') {
       selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
       selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
     }
     }
 
 
-    var $parent = selector && $(selector)
+    var $parent = selector !== '#' ? $(document).find(selector) : null
 
 
     return $parent && $parent.length ? $parent : $this.parent()
     return $parent && $parent.length ? $parent : $this.parent()
   }
   }
@@ -915,10 +925,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: modal.js v3.3.7
- * http://getbootstrap.com/javascript/#modals
+ * Bootstrap: modal.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#modals
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -930,15 +940,16 @@ if (typeof jQuery === 'undefined') {
   // ======================
   // ======================
 
 
   var Modal = function (element, options) {
   var Modal = function (element, options) {
-    this.options             = options
-    this.$body               = $(document.body)
-    this.$element            = $(element)
-    this.$dialog             = this.$element.find('.modal-dialog')
-    this.$backdrop           = null
-    this.isShown             = null
-    this.originalBodyPad     = null
-    this.scrollbarWidth      = 0
+    this.options = options
+    this.$body = $(document.body)
+    this.$element = $(element)
+    this.$dialog = this.$element.find('.modal-dialog')
+    this.$backdrop = null
+    this.isShown = null
+    this.originalBodyPad = null
+    this.scrollbarWidth = 0
     this.ignoreBackdropClick = false
     this.ignoreBackdropClick = false
+    this.fixedContent = '.navbar-fixed-top, .navbar-fixed-bottom'
 
 
     if (this.options.remote) {
     if (this.options.remote) {
       this.$element
       this.$element
@@ -949,7 +960,7 @@ if (typeof jQuery === 'undefined') {
     }
     }
   }
   }
 
 
-  Modal.VERSION  = '3.3.7'
+  Modal.VERSION = '3.4.1'
 
 
   Modal.TRANSITION_DURATION = 300
   Modal.TRANSITION_DURATION = 300
   Modal.BACKDROP_TRANSITION_DURATION = 150
   Modal.BACKDROP_TRANSITION_DURATION = 150
@@ -966,7 +977,7 @@ if (typeof jQuery === 'undefined') {
 
 
   Modal.prototype.show = function (_relatedTarget) {
   Modal.prototype.show = function (_relatedTarget) {
     var that = this
     var that = this
-    var e    = $.Event('show.bs.modal', { relatedTarget: _relatedTarget })
+    var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget })
 
 
     this.$element.trigger(e)
     this.$element.trigger(e)
 
 
@@ -1057,8 +1068,8 @@ if (typeof jQuery === 'undefined') {
       .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 (document !== e.target &&
         if (document !== e.target &&
-            this.$element[0] !== e.target &&
-            !this.$element.has(e.target).length) {
+          this.$element[0] !== e.target &&
+          !this.$element.has(e.target).length) {
           this.$element.trigger('focus')
           this.$element.trigger('focus')
         }
         }
       }, this))
       }, this))
@@ -1160,7 +1171,7 @@ if (typeof jQuery === 'undefined') {
     var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight
     var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight
 
 
     this.$element.css({
     this.$element.css({
-      paddingLeft:  !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '',
+      paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '',
       paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : ''
       paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : ''
     })
     })
   }
   }
@@ -1185,11 +1196,26 @@ if (typeof jQuery === 'undefined') {
   Modal.prototype.setScrollbar = function () {
   Modal.prototype.setScrollbar = function () {
     var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10)
     var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10)
     this.originalBodyPad = document.body.style.paddingRight || ''
     this.originalBodyPad = document.body.style.paddingRight || ''
-    if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth)
+    var scrollbarWidth = this.scrollbarWidth
+    if (this.bodyIsOverflowing) {
+      this.$body.css('padding-right', bodyPad + scrollbarWidth)
+      $(this.fixedContent).each(function (index, element) {
+        var actualPadding = element.style.paddingRight
+        var calculatedPadding = $(element).css('padding-right')
+        $(element)
+          .data('padding-right', actualPadding)
+          .css('padding-right', parseFloat(calculatedPadding) + scrollbarWidth + 'px')
+      })
+    }
   }
   }
 
 
   Modal.prototype.resetScrollbar = function () {
   Modal.prototype.resetScrollbar = function () {
     this.$body.css('padding-right', this.originalBodyPad)
     this.$body.css('padding-right', this.originalBodyPad)
+    $(this.fixedContent).each(function (index, element) {
+      var padding = $(element).data('padding-right')
+      $(element).removeData('padding-right')
+      element.style.paddingRight = padding ? padding : ''
+    })
   }
   }
 
 
   Modal.prototype.measureScrollbar = function () { // thx walsh
   Modal.prototype.measureScrollbar = function () { // thx walsh
@@ -1207,8 +1233,8 @@ if (typeof jQuery === 'undefined') {
 
 
   function Plugin(option, _relatedTarget) {
   function Plugin(option, _relatedTarget) {
     return this.each(function () {
     return this.each(function () {
-      var $this   = $(this)
-      var data    = $this.data('bs.modal')
+      var $this = $(this)
+      var data = $this.data('bs.modal')
       var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option)
       var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option)
 
 
       if (!data) $this.data('bs.modal', (data = new Modal(this, options)))
       if (!data) $this.data('bs.modal', (data = new Modal(this, options)))
@@ -1219,7 +1245,7 @@ if (typeof jQuery === 'undefined') {
 
 
   var old = $.fn.modal
   var old = $.fn.modal
 
 
-  $.fn.modal             = Plugin
+  $.fn.modal = Plugin
   $.fn.modal.Constructor = Modal
   $.fn.modal.Constructor = Modal
 
 
 
 
@@ -1236,10 +1262,13 @@ if (typeof jQuery === 'undefined') {
   // ==============
   // ==============
 
 
   $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
   $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
-    var $this   = $(this)
-    var href    = $this.attr('href')
-    var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7
-    var option  = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
+    var $this = $(this)
+    var href = $this.attr('href')
+    var target = $this.attr('data-target') ||
+      (href && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7
+
+    var $target = $(document).find(target)
+    var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
 
 
     if ($this.is('a')) e.preventDefault()
     if ($this.is('a')) e.preventDefault()
 
 
@@ -1255,18 +1284,148 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: tooltip.js v3.3.7
- * http://getbootstrap.com/javascript/#tooltip
+ * Bootstrap: tooltip.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#tooltip
  * Inspired by the original jQuery.tipsy by Jason Frame
  * Inspired by the original jQuery.tipsy by Jason Frame
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
-
 +function ($) {
 +function ($) {
   'use strict';
   'use strict';
 
 
+  var DISALLOWED_ATTRIBUTES = ['sanitize', 'whiteList', 'sanitizeFn']
+
+  var uriAttrs = [
+    'background',
+    'cite',
+    'href',
+    'itemtype',
+    'longdesc',
+    'poster',
+    'src',
+    'xlink:href'
+  ]
+
+  var ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i
+
+  var DefaultWhitelist = {
+    // Global attributes allowed on any supplied element below.
+    '*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN],
+    a: ['target', 'href', 'title', 'rel'],
+    area: [],
+    b: [],
+    br: [],
+    col: [],
+    code: [],
+    div: [],
+    em: [],
+    hr: [],
+    h1: [],
+    h2: [],
+    h3: [],
+    h4: [],
+    h5: [],
+    h6: [],
+    i: [],
+    img: ['src', 'alt', 'title', 'width', 'height'],
+    li: [],
+    ol: [],
+    p: [],
+    pre: [],
+    s: [],
+    small: [],
+    span: [],
+    sub: [],
+    sup: [],
+    strong: [],
+    u: [],
+    ul: []
+  }
+
+  /**
+   * A pattern that recognizes a commonly useful subset of URLs that are safe.
+   *
+   * Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts
+   */
+  var SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi
+
+  /**
+   * A pattern that matches safe data URLs. Only matches image, video and audio types.
+   *
+   * Shoutout to Angular 7 https://github.com/angular/angular/blob/7.2.4/packages/core/src/sanitization/url_sanitizer.ts
+   */
+  var DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i
+
+  function allowedAttribute(attr, allowedAttributeList) {
+    var attrName = attr.nodeName.toLowerCase()
+
+    if ($.inArray(attrName, allowedAttributeList) !== -1) {
+      if ($.inArray(attrName, uriAttrs) !== -1) {
+        return Boolean(attr.nodeValue.match(SAFE_URL_PATTERN) || attr.nodeValue.match(DATA_URL_PATTERN))
+      }
+
+      return true
+    }
+
+    var regExp = $(allowedAttributeList).filter(function (index, value) {
+      return value instanceof RegExp
+    })
+
+    // Check if a regular expression validates the attribute.
+    for (var i = 0, l = regExp.length; i < l; i++) {
+      if (attrName.match(regExp[i])) {
+        return true
+      }
+    }
+
+    return false
+  }
+
+  function sanitizeHtml(unsafeHtml, whiteList, sanitizeFn) {
+    if (unsafeHtml.length === 0) {
+      return unsafeHtml
+    }
+
+    if (sanitizeFn && typeof sanitizeFn === 'function') {
+      return sanitizeFn(unsafeHtml)
+    }
+
+    // IE 8 and below don't support createHTMLDocument
+    if (!document.implementation || !document.implementation.createHTMLDocument) {
+      return unsafeHtml
+    }
+
+    var createdDocument = document.implementation.createHTMLDocument('sanitization')
+    createdDocument.body.innerHTML = unsafeHtml
+
+    var whitelistKeys = $.map(whiteList, function (el, i) { return i })
+    var elements = $(createdDocument.body).find('*')
+
+    for (var i = 0, len = elements.length; i < len; i++) {
+      var el = elements[i]
+      var elName = el.nodeName.toLowerCase()
+
+      if ($.inArray(elName, whitelistKeys) === -1) {
+        el.parentNode.removeChild(el)
+
+        continue
+      }
+
+      var attributeList = $.map(el.attributes, function (el) { return el })
+      var whitelistedAttributes = [].concat(whiteList['*'] || [], whiteList[elName] || [])
+
+      for (var j = 0, len2 = attributeList.length; j < len2; j++) {
+        if (!allowedAttribute(attributeList[j], whitelistedAttributes)) {
+          el.removeAttribute(attributeList[j].nodeName)
+        }
+      }
+    }
+
+    return createdDocument.body.innerHTML
+  }
+
   // TOOLTIP PUBLIC CLASS DEFINITION
   // TOOLTIP PUBLIC CLASS DEFINITION
   // ===============================
   // ===============================
 
 
@@ -1282,7 +1441,7 @@ if (typeof jQuery === 'undefined') {
     this.init('tooltip', element, options)
     this.init('tooltip', element, options)
   }
   }
 
 
-  Tooltip.VERSION  = '3.3.7'
+  Tooltip.VERSION  = '3.4.1'
 
 
   Tooltip.TRANSITION_DURATION = 150
   Tooltip.TRANSITION_DURATION = 150
 
 
@@ -1299,7 +1458,10 @@ if (typeof jQuery === 'undefined') {
     viewport: {
     viewport: {
       selector: 'body',
       selector: 'body',
       padding: 0
       padding: 0
-    }
+    },
+    sanitize : true,
+    sanitizeFn : null,
+    whiteList : DefaultWhitelist
   }
   }
 
 
   Tooltip.prototype.init = function (type, element, options) {
   Tooltip.prototype.init = function (type, element, options) {
@@ -1307,7 +1469,7 @@ if (typeof jQuery === 'undefined') {
     this.type      = type
     this.type      = type
     this.$element  = $(element)
     this.$element  = $(element)
     this.options   = this.getOptions(options)
     this.options   = this.getOptions(options)
-    this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport))
+    this.$viewport = this.options.viewport && $(document).find($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport))
     this.inState   = { click: false, hover: false, focus: false }
     this.inState   = { click: false, hover: false, focus: false }
 
 
     if (this.$element[0] instanceof document.constructor && !this.options.selector) {
     if (this.$element[0] instanceof document.constructor && !this.options.selector) {
@@ -1340,7 +1502,15 @@ if (typeof jQuery === 'undefined') {
   }
   }
 
 
   Tooltip.prototype.getOptions = function (options) {
   Tooltip.prototype.getOptions = function (options) {
-    options = $.extend({}, this.getDefaults(), this.$element.data(), options)
+    var dataAttributes = this.$element.data()
+
+    for (var dataAttr in dataAttributes) {
+      if (dataAttributes.hasOwnProperty(dataAttr) && $.inArray(dataAttr, DISALLOWED_ATTRIBUTES) !== -1) {
+        delete dataAttributes[dataAttr]
+      }
+    }
+
+    options = $.extend({}, this.getDefaults(), dataAttributes, options)
 
 
     if (options.delay && typeof options.delay == 'number') {
     if (options.delay && typeof options.delay == 'number') {
       options.delay = {
       options.delay = {
@@ -1349,6 +1519,10 @@ if (typeof jQuery === 'undefined') {
       }
       }
     }
     }
 
 
+    if (options.sanitize) {
+      options.template = sanitizeHtml(options.template, options.whiteList, options.sanitizeFn)
+    }
+
     return options
     return options
   }
   }
 
 
@@ -1460,7 +1634,7 @@ if (typeof jQuery === 'undefined') {
         .addClass(placement)
         .addClass(placement)
         .data('bs.' + this.type, this)
         .data('bs.' + this.type, this)
 
 
-      this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element)
+      this.options.container ? $tip.appendTo($(document).find(this.options.container)) : $tip.insertAfter(this.$element)
       this.$element.trigger('inserted.bs.' + this.type)
       this.$element.trigger('inserted.bs.' + this.type)
 
 
       var pos          = this.getPosition()
       var pos          = this.getPosition()
@@ -1562,7 +1736,16 @@ if (typeof jQuery === 'undefined') {
     var $tip  = this.tip()
     var $tip  = this.tip()
     var title = this.getTitle()
     var title = this.getTitle()
 
 
-    $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title)
+    if (this.options.html) {
+      if (this.options.sanitize) {
+        title = sanitizeHtml(title, this.options.whiteList, this.options.sanitizeFn)
+      }
+
+      $tip.find('.tooltip-inner').html(title)
+    } else {
+      $tip.find('.tooltip-inner').text(title)
+    }
+
     $tip.removeClass('fade in top bottom left right')
     $tip.removeClass('fade in top bottom left right')
   }
   }
 
 
@@ -1743,6 +1926,9 @@ if (typeof jQuery === 'undefined') {
     })
     })
   }
   }
 
 
+  Tooltip.prototype.sanitizeHtml = function (unsafeHtml) {
+    return sanitizeHtml(unsafeHtml, this.options.whiteList, this.options.sanitizeFn)
+  }
 
 
   // TOOLTIP PLUGIN DEFINITION
   // TOOLTIP PLUGIN DEFINITION
   // =========================
   // =========================
@@ -1776,10 +1962,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: popover.js v3.3.7
- * http://getbootstrap.com/javascript/#popovers
+ * Bootstrap: popover.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#popovers
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -1796,7 +1982,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.7'
+  Popover.VERSION  = '3.4.1'
 
 
   Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
   Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
     placement: 'right',
     placement: 'right',
@@ -1822,10 +2008,25 @@ if (typeof jQuery === 'undefined') {
     var title   = this.getTitle()
     var title   = this.getTitle()
     var content = this.getContent()
     var content = this.getContent()
 
 
-    $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title)
-    $tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events
-      this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text'
-    ](content)
+    if (this.options.html) {
+      var typeContent = typeof content
+
+      if (this.options.sanitize) {
+        title = this.sanitizeHtml(title)
+
+        if (typeContent === 'string') {
+          content = this.sanitizeHtml(content)
+        }
+      }
+
+      $tip.find('.popover-title').html(title)
+      $tip.find('.popover-content').children().detach().end()[
+        typeContent === 'string' ? 'html' : 'append'
+      ](content)
+    } else {
+      $tip.find('.popover-title').text(title)
+      $tip.find('.popover-content').children().detach().end().text(content)
+    }
 
 
     $tip.removeClass('fade top bottom left right in')
     $tip.removeClass('fade top bottom left right in')
 
 
@@ -1844,8 +2045,8 @@ if (typeof jQuery === 'undefined') {
 
 
     return $e.attr('data-content')
     return $e.attr('data-content')
       || (typeof o.content == 'function' ?
       || (typeof o.content == 'function' ?
-            o.content.call($e[0]) :
-            o.content)
+        o.content.call($e[0]) :
+        o.content)
   }
   }
 
 
   Popover.prototype.arrow = function () {
   Popover.prototype.arrow = function () {
@@ -1885,10 +2086,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: scrollspy.js v3.3.7
- * http://getbootstrap.com/javascript/#scrollspy
+ * Bootstrap: scrollspy.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#scrollspy
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -1914,7 +2115,7 @@ if (typeof jQuery === 'undefined') {
     this.process()
     this.process()
   }
   }
 
 
-  ScrollSpy.VERSION  = '3.3.7'
+  ScrollSpy.VERSION  = '3.4.1'
 
 
   ScrollSpy.DEFAULTS = {
   ScrollSpy.DEFAULTS = {
     offset: 10
     offset: 10
@@ -2058,10 +2259,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: tab.js v3.3.7
- * http://getbootstrap.com/javascript/#tabs
+ * Bootstrap: tab.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#tabs
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -2078,7 +2279,7 @@ if (typeof jQuery === 'undefined') {
     // jscs:enable requireDollarBeforejQueryAssignment
     // jscs:enable requireDollarBeforejQueryAssignment
   }
   }
 
 
-  Tab.VERSION = '3.3.7'
+  Tab.VERSION = '3.4.1'
 
 
   Tab.TRANSITION_DURATION = 150
   Tab.TRANSITION_DURATION = 150
 
 
@@ -2107,7 +2308,7 @@ if (typeof jQuery === 'undefined') {
 
 
     if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return
     if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return
 
 
-    var $target = $(selector)
+    var $target = $(document).find(selector)
 
 
     this.activate($this.closest('li'), $ul)
     this.activate($this.closest('li'), $ul)
     this.activate($target, $target.parent(), function () {
     this.activate($target, $target.parent(), function () {
@@ -2132,15 +2333,15 @@ if (typeof jQuery === 'undefined') {
       $active
       $active
         .removeClass('active')
         .removeClass('active')
         .find('> .dropdown-menu > .active')
         .find('> .dropdown-menu > .active')
-          .removeClass('active')
+        .removeClass('active')
         .end()
         .end()
         .find('[data-toggle="tab"]')
         .find('[data-toggle="tab"]')
-          .attr('aria-expanded', false)
+        .attr('aria-expanded', false)
 
 
       element
       element
         .addClass('active')
         .addClass('active')
         .find('[data-toggle="tab"]')
         .find('[data-toggle="tab"]')
-          .attr('aria-expanded', true)
+        .attr('aria-expanded', true)
 
 
       if (transition) {
       if (transition) {
         element[0].offsetWidth // reflow for transition
         element[0].offsetWidth // reflow for transition
@@ -2152,10 +2353,10 @@ if (typeof jQuery === 'undefined') {
       if (element.parent('.dropdown-menu').length) {
       if (element.parent('.dropdown-menu').length) {
         element
         element
           .closest('li.dropdown')
           .closest('li.dropdown')
-            .addClass('active')
+          .addClass('active')
           .end()
           .end()
           .find('[data-toggle="tab"]')
           .find('[data-toggle="tab"]')
-            .attr('aria-expanded', true)
+          .attr('aria-expanded', true)
       }
       }
 
 
       callback && callback()
       callback && callback()
@@ -2214,10 +2415,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 }(jQuery);
 
 
 /* ========================================================================
 /* ========================================================================
- * Bootstrap: affix.js v3.3.7
- * http://getbootstrap.com/javascript/#affix
+ * Bootstrap: affix.js v3.4.1
+ * https://getbootstrap.com/docs/3.4/javascript/#affix
  * ========================================================================
  * ========================================================================
- * Copyright 2011-2016 Twitter, Inc.
+ * Copyright 2011-2019 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
  * ======================================================================== */
 
 
@@ -2231,7 +2432,9 @@ if (typeof jQuery === 'undefined') {
   var Affix = function (element, options) {
   var Affix = function (element, options) {
     this.options = $.extend({}, Affix.DEFAULTS, options)
     this.options = $.extend({}, Affix.DEFAULTS, options)
 
 
-    this.$target = $(this.options.target)
+    var target = this.options.target === Affix.DEFAULTS.target ? $(this.options.target) : $(document).find(this.options.target)
+
+    this.$target = target
       .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this))
       .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this))
       .on('click.bs.affix.data-api',  $.proxy(this.checkPositionWithEventLoop, this))
       .on('click.bs.affix.data-api',  $.proxy(this.checkPositionWithEventLoop, this))
 
 
@@ -2243,7 +2446,7 @@ if (typeof jQuery === 'undefined') {
     this.checkPosition()
     this.checkPosition()
   }
   }
 
 
-  Affix.VERSION  = '3.3.7'
+  Affix.VERSION  = '3.4.1'
 
 
   Affix.RESET    = 'affix affix-top affix-bottom'
   Affix.RESET    = 'affix affix-top affix-bottom'
 
 

Разлика између датотеке није приказан због своје велике величине
+ 5 - 0
netbox/project-static/bootstrap-3.4.1-dist/js/bootstrap.min.js


+ 0 - 0
netbox/project-static/bootstrap-3.3.7-dist/js/npm.js → netbox/project-static/bootstrap-3.4.1-dist/js/npm.js


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

@@ -42,8 +42,8 @@ footer p {
     }
     }
 }
 }
 
 
-/* Hide the search bar in the navigation menu on displays less than 1200px wide */
-@media (max-width: 1199px) {
+/* Hide the search bar in the navigation menu on displays less than 1250px wide */
+@media (max-width: 1249px) {
     #navbar_search {
     #navbar_search {
         display: none;
         display: none;
     }
     }
@@ -62,8 +62,8 @@ footer p {
     }
     }
 }
 }
 
 
-/* Collapse the nav menu on displays less than 960px wide */
-@media (max-width: 959px) {
+/* Collapse the nav menu on displays less than 980px wide */
+@media (max-width: 979px) {
     .navbar-header {
     .navbar-header {
         float: none;
         float: none;
     }
     }
@@ -529,6 +529,9 @@ table.report th a {
     border-top: 1px solid #dddddd;
     border-top: 1px solid #dddddd;
     padding: 8px;
     padding: 8px;
 }
 }
+.rendered-markdown :last-child {
+    margin-bottom: 0;
+}
 
 
 /* AJAX loader */
 /* AJAX loader */
 .loading {
 .loading {

+ 0 - 0
netbox/scripts/__init__.py


+ 66 - 0
netbox/scripts/examples.py

@@ -0,0 +1,66 @@
+from django.utils.text import slugify
+
+from dcim.constants import *
+from dcim.models import Device, DeviceRole, DeviceType, Site
+from extras.scripts import *
+
+
+class NewBranchScript(Script):
+    script_name = "New Branch"
+    script_description = "Provision a new branch site"
+    script_fields = ['site_name', 'switch_count', 'switch_model']
+
+    site_name = StringVar(
+        description="Name of the new site"
+    )
+    switch_count = IntegerVar(
+        description="Number of access switches to create"
+    )
+    switch_model = ObjectVar(
+        description="Access switch model",
+        queryset=DeviceType.objects.filter(
+            manufacturer__name='Cisco',
+            model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
+        )
+    )
+    x = BooleanVar(
+        description="Check me out"
+    )
+
+    def run(self, data):
+
+        # Create the new site
+        site = Site(
+            name=data['site_name'],
+            slug=slugify(data['site_name']),
+            status=SITE_STATUS_PLANNED
+        )
+        site.save()
+        self.log_success("Created new site: {}".format(site))
+
+        # Create access switches
+        switch_role = DeviceRole.objects.get(name='Access Switch')
+        for i in range(1, data['switch_count'] + 1):
+            switch = Device(
+                device_type=data['switch_model'],
+                name='{}-switch{}'.format(site.slug, i),
+                site=site,
+                status=DEVICE_STATUS_PLANNED,
+                device_role=switch_role
+            )
+            switch.save()
+            self.log_success("Created new switch: {}".format(switch))
+
+        # Generate a CSV table of new devices
+        output = [
+            'name,make,model'
+        ]
+        for switch in Device.objects.filter(site=site):
+            attrs = [
+                switch.name,
+                switch.device_type.manufacturer.name,
+                switch.device_type.model
+            ]
+            output.append(','.join(attrs))
+
+        return '\n'.join(output)

+ 54 - 0
netbox/scripts/myscripts.py

@@ -0,0 +1,54 @@
+from dcim.models import Site
+from extras.scripts import Script, BooleanVar, IntegerVar, ObjectVar, StringVar
+
+
+class NoInputScript(Script):
+    description = "This script does not require any input"
+
+    def run(self, data):
+
+        self.log_debug("This a debug message.")
+        self.log_info("This an info message.")
+        self.log_success("This a success message.")
+        self.log_warning("This a warning message.")
+        self.log_failure("This a failure message.")
+
+
+class DemoScript(Script):
+    name = "Script Demo"
+    description = "A quick demonstration of the available field types"
+
+    my_string1 = StringVar(
+        description="Input a string between 3 and 10 characters",
+        min_length=3,
+        max_length=10
+    )
+    my_string2 = StringVar(
+        description="This field enforces a regex: three letters followed by three numbers",
+        regex=r'[a-z]{3}\d{3}'
+    )
+    my_number = IntegerVar(
+        description="Pick a number between 1 and 255 (inclusive)",
+        min_value=1,
+        max_value=255
+    )
+    my_boolean = BooleanVar(
+        description="Use the checkbox to toggle true/false"
+    )
+    my_object = ObjectVar(
+        description="Select a NetBox site",
+        queryset=Site.objects.all()
+    )
+
+    def run(self, data):
+
+        self.log_info("Your string was {}".format(data['my_string1']))
+        self.log_info("Your second string was {}".format(data['my_string2']))
+        self.log_info("Your number was {}".format(data['my_number']))
+        if data['my_boolean']:
+            self.log_info("You ticked the checkbox")
+        else:
+            self.log_info("You did not tick the checkbox")
+        self.log_info("You chose the sites {}".format(data['my_object']))
+
+        return "Here's some output"

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

@@ -46,10 +46,8 @@ class SecretRoleViewSet(ModelViewSet):
 #
 #
 
 
 class SecretViewSet(ModelViewSet):
 class SecretViewSet(ModelViewSet):
-    queryset = Secret.objects.select_related(
-        'device__primary_ip4', 'device__primary_ip6', 'role',
-    ).prefetch_related(
-        'role__users', 'role__groups', 'tags',
+    queryset = Secret.objects.prefetch_related(
+        'device__primary_ip4', 'device__primary_ip6', 'role', 'role__users', 'role__groups', 'tags',
     )
     )
     serializer_class = serializers.SecretSerializer
     serializer_class = serializers.SecretSerializer
     filterset_class = filters.SecretFilter
     filterset_class = filters.SecretFilter

+ 3 - 3
netbox/secrets/views.py

@@ -69,7 +69,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class SecretListView(PermissionRequiredMixin, ObjectListView):
 class SecretListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'secrets.view_secret'
     permission_required = 'secrets.view_secret'
-    queryset = Secret.objects.select_related('role', 'device')
+    queryset = Secret.objects.prefetch_related('role', 'device')
     filter = filters.SecretFilter
     filter = filters.SecretFilter
     filter_form = forms.SecretFilterForm
     filter_form = forms.SecretFilterForm
     table = tables.SecretTable
     table = tables.SecretTable
@@ -247,7 +247,7 @@ class SecretBulkImportView(BulkImportView):
 
 
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'secrets.change_secret'
     permission_required = 'secrets.change_secret'
-    queryset = Secret.objects.select_related('role', 'device')
+    queryset = Secret.objects.prefetch_related('role', 'device')
     filter = filters.SecretFilter
     filter = filters.SecretFilter
     table = tables.SecretTable
     table = tables.SecretTable
     form = forms.SecretBulkEditForm
     form = forms.SecretBulkEditForm
@@ -256,7 +256,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secret'
     permission_required = 'secrets.delete_secret'
-    queryset = Secret.objects.select_related('role', 'device')
+    queryset = Secret.objects.prefetch_related('role', 'device')
     filter = filters.SecretFilter
     filter = filters.SecretFilter
     table = tables.SecretTable
     table = tables.SecretTable
     default_return_url = 'secrets:secret_list'
     default_return_url = 'secrets:secret_list'

+ 1 - 1
netbox/templates/500.html

@@ -4,7 +4,7 @@
 
 
 <head>
 <head>
     <title>Server Error</title>
     <title>Server Error</title>
-    <link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
+    <link rel="stylesheet" href="{% static 'bootstrap-3.4.1-dist/css/bootstrap.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
     <meta charset="UTF-8">
     <meta charset="UTF-8">
 </head>
 </head>

+ 2 - 2
netbox/templates/_base.html

@@ -4,7 +4,7 @@
 <html lang="en">
 <html lang="en">
 <head>
 <head>
     <title>{% block title %}Home{% endblock %} - NetBox</title>
     <title>{% block title %}Home{% endblock %} - NetBox</title>
-    <link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
+    <link rel="stylesheet" href="{% static 'bootstrap-3.4.1-dist/css/bootstrap.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
     <link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
     <link rel="stylesheet" href="{% static 'select2-4.0.5/css/select2.min.css' %}">
     <link rel="stylesheet" href="{% static 'select2-4.0.5/css/select2.min.css' %}">
@@ -67,7 +67,7 @@
     </footer>
     </footer>
 <script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
 <script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
-<script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
+<script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"></script>
 <script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
 <script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
 <script src="{% static 'clipboard-2.0.4.min.js' %}"></script>
 <script src="{% static 'clipboard-2.0.4.min.js' %}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>

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

@@ -239,7 +239,7 @@
                         <td>Platform</td>
                         <td>Platform</td>
                         <td>
                         <td>
                             {% if device.platform %}
                             {% if device.platform %}
-                                <span>{{ device.platform }}</span>
+                               <a href="{{ device.platform.get_absolute_url }}">{{ device.platform }}</a>
                             {% else %}
                             {% else %}
                                 <span class="text-muted">None</span>
                                 <span class="text-muted">None</span>
                             {% endif %}
                             {% endif %}

+ 111 - 0
netbox/templates/extras/script.html

@@ -0,0 +1,111 @@
+{% extends '_base.html' %}
+{% load helpers %}
+{% load form_helpers %}
+{% load log_levels %}
+
+{% block title %}{{ script }}{% endblock %}
+
+{% block content %}
+    <div class="row noprint">
+        <div class="col-md-12">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
+                <li><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
+                <li>{{ script }}</li>
+            </ol>
+        </div>
+    </div>
+    <h1>{{ script }}</h1>
+    <p>{{ script.Meta.description }}</p>
+    <ul class="nav nav-tabs" role="tablist">
+        <li role="presentation" class="active">
+            <a href="#run" role="tab" data-toggle="tab" class="active">Run</a>
+        </li>
+        <li role="presentation"{% if not output %} class="disabled"{% endif %}>
+            <a href="#output" role="tab" data-toggle="tab">Output</a>
+        </li>
+        <li role="presentation">
+            <a href="#source" role="tab" data-toggle="tab">Source</a>
+        </li>
+    </ul>
+    <div class="tab-content">
+        <div role="tabpanel" class="tab-pane active" id="run">
+            {% if execution_time or script.log %}
+                <div class="row">
+                    <div class="col-md-12">
+                        <div class="panel panel-default">
+                            <div class="panel-heading">
+                                <strong>Script Log</strong>
+                            </div>
+                            <table class="table table-hover panel-body">
+                                <tr>
+                                    <th>Line</th>
+                                    <th>Level</th>
+                                    <th>Message</th>
+                                </tr>
+                                {% for level, message in script.log %}
+                                    <tr>
+                                        <td>{{ forloop.counter }}</td>
+                                        <td>{% log_level level %}</td>
+                                        <td class="rendered-markdown">{{ message|gfm }}</td>
+                                    </tr>
+                                {% empty %}
+                                    <tr>
+                                        <td colspan="3" class="text-center text-muted">
+                                            No log output
+                                        </td>
+                                    </tr>
+                                {% endfor %}
+                            </table>
+                            {% if execution_time %}
+                                <div class="panel-footer text-right text-muted">
+                                    <small>Exec time: {{ execution_time|floatformat:3 }}s</small>
+                                </div>
+                            {% endif %}
+                        </div>
+                    </div>
+                </div>
+            {% endif %}
+            <div class="row">
+                <div class="col-md-6 col-md-offset-3">
+                    {% if not perms.extras.run_script %}
+                        <div class="alert alert-warning">
+                            <i class="fa fa-warning"></i>
+                            You do not have permission to run scripts.
+                        </div>
+                    {% endif %}
+                    <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
+                        {% csrf_token %}
+                        {% if form.requires_input %}
+                            <div class="panel panel-default">
+                                <div class="panel-heading">
+                                    <strong>Script Data</strong>
+                                </div>
+                                <div class="panel-body">
+                                    {% render_form form %}
+                                </div>
+                            </div>
+                        {% else %}
+                             <div class="alert alert-info">
+                                <i class="fa fa-exclamation-circle"></i>
+                                This script does not require any input to run.
+                            </div>
+                            {% render_form form %}
+                        {% endif %}
+                        <div class="pull-right">
+                            <button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="fa fa-play"></i> Run Script</button>
+                            <a href="{% url 'extras:script_list' %}" class="btn btn-default">Cancel</a>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+        <div role="tabpanel" class="tab-pane" id="output">
+            <pre>{{ output }}</pre>
+        </div>
+        <div role="tabpanel" class="tab-pane" id="source">
+            <p><code>{{ script.filename }}</code></p>
+            <pre>{{ script.source }}</pre>
+        </div>
+    </div>
+{% endblock %}

+ 38 - 0
netbox/templates/extras/script_list.html

@@ -0,0 +1,38 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+    <h1>{% block title %}Scripts{% endblock %}</h1>
+    <div class="row">
+        <div class="col-md-12">
+            {% if scripts %}
+                {% for module, module_scripts in scripts.items %}
+                    <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
+                    <table class="table table-hover table-headings reports">
+                        <thead>
+                            <tr>
+                                <th class="col-md-3">Name</th>
+                                <th class="col-md-9">Description</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for class_name, script in module_scripts.items %}
+                                <tr>
+                                    <td>
+                                        <a href="{% url 'extras:script' module=module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
+                                    </td>
+                                    <td>{{ script.Meta.description }}</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                {% endfor %}
+            {% else %}
+                <div class="alert alert-info">
+                    <p><strong>No scripts found.</strong></p>
+                    <p>Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>. (This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.)</p>
+                </div>
+            {% endif %}
+        </div>
+    </div>
+{% endblock %}

+ 1 - 1
netbox/templates/extras/tag_list.html

@@ -5,7 +5,7 @@
 <h1>{% block title %}Tags{% endblock %}</h1>
 <h1>{% block title %}Tags{% endblock %}</h1>
 <div class="row">
 <div class="row">
 	<div class="col-md-9">
 	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
+        {% include 'utilities/obj_table.html' with bulk_edit_url='extras:tag_bulk_edit' bulk_delete_url='extras:tag_bulk_delete' %}
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}

+ 1 - 0
netbox/templates/extras/templatetags/log_level.html

@@ -0,0 +1 @@
+<label class="label label-{{ class }}">{{ name }}</label>

+ 3 - 0
netbox/templates/inc/nav_menu.html

@@ -66,6 +66,9 @@
                         <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
                         <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                         </li>
                         </li>
+                        <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
+                            <a href="{% url 'extras:script_list' %}">Scripts</a>
+                        </li>
                         <li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
                         <li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                         </li>
                         </li>

+ 3 - 5
netbox/templates/utilities/templatetags/tag.html

@@ -1,7 +1,5 @@
 {% load helpers %}
 {% load helpers %}
 
 
-{% if url_name %}
-    <a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span></a>
-{% else %}
-    <span class="label label-default">{{ tag }}</span>
-{% endif %}
+{% if url_name %}<a href="{% url url_name %}?tag={{ tag.slug }}">{% endif %}
+<span class="label label-default" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span>
+{% if url_name %}</a>{% endif %}

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

@@ -35,10 +35,8 @@ class TenantGroupViewSet(ModelViewSet):
 #
 #
 
 
 class TenantViewSet(CustomFieldModelViewSet):
 class TenantViewSet(CustomFieldModelViewSet):
-    queryset = Tenant.objects.select_related(
-        'group'
-    ).prefetch_related(
-        'tags'
+    queryset = Tenant.objects.prefetch_related(
+        'group', 'tags'
     ).annotate(
     ).annotate(
         circuit_count=get_subquery(Circuit, 'tenant'),
         circuit_count=get_subquery(Circuit, 'tenant'),
         device_count=get_subquery(Device, 'tenant'),
         device_count=get_subquery(Device, 'tenant'),

+ 3 - 3
netbox/tenancy/views.py

@@ -56,7 +56,7 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class TenantListView(PermissionRequiredMixin, ObjectListView):
 class TenantListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'tenancy.view_tenant'
     permission_required = 'tenancy.view_tenant'
-    queryset = Tenant.objects.select_related('group')
+    queryset = Tenant.objects.prefetch_related('group')
     filter = filters.TenantFilter
     filter = filters.TenantFilter
     filter_form = forms.TenantFilterForm
     filter_form = forms.TenantFilterForm
     table = tables.TenantTable
     table = tables.TenantTable
@@ -115,7 +115,7 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
 class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'tenancy.change_tenant'
     permission_required = 'tenancy.change_tenant'
-    queryset = Tenant.objects.select_related('group')
+    queryset = Tenant.objects.prefetch_related('group')
     filter = filters.TenantFilter
     filter = filters.TenantFilter
     table = tables.TenantTable
     table = tables.TenantTable
     form = forms.TenantBulkEditForm
     form = forms.TenantBulkEditForm
@@ -124,7 +124,7 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenant'
     permission_required = 'tenancy.delete_tenant'
-    queryset = Tenant.objects.select_related('group')
+    queryset = Tenant.objects.prefetch_related('group')
     filter = filters.TenantFilter
     filter = filters.TenantFilter
     table = tables.TenantTable
     table = tables.TenantTable
     default_return_url = 'tenancy:tenant_list'
     default_return_url = 'tenancy:tenant_list'

+ 9 - 6
netbox/utilities/api.py

@@ -85,9 +85,9 @@ class ChoiceField(Field):
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
 
 
-        # Provide an explicit error message if the request is trying to write a dict
-        if type(data) is dict:
-            raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary.')
+        # Provide an explicit error message if the request is trying to write a dict or list
+        if isinstance(data, (dict, list)):
+            raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
 
 
         # Check for string representations of boolean/integer values
         # Check for string representations of boolean/integer values
         if hasattr(data, 'lower'):
         if hasattr(data, 'lower'):
@@ -101,10 +101,13 @@ class ChoiceField(Field):
                 except ValueError:
                 except ValueError:
                     pass
                     pass
 
 
-        if data not in self._choices:
-            raise ValidationError("{} is not a valid choice.".format(data))
+        try:
+            if data in self._choices:
+                return data
+        except TypeError:  # Input is an unhashable type
+            pass
 
 
-        return data
+        raise ValidationError("{} is not a valid choice.".format(data))
 
 
     @property
     @property
     def choices(self):
     def choices(self):

+ 5 - 0
netbox/utilities/exceptions.py

@@ -0,0 +1,5 @@
+class AbortTransaction(Exception):
+    """
+    A dummy exception used to trigger a database transaction rollback.
+    """
+    pass

+ 5 - 6
netbox/utilities/models.py

@@ -23,15 +23,14 @@ class ChangeLoggedModel(models.Model):
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
-    def log_change(self, user, request_id, action):
+    def to_objectchange(self, action):
         """
         """
-        Create a new ObjectChange representing a change made to this object. This will typically be called automatically
+        Return a new ObjectChange representing a change made to this object. This will typically be called automatically
         by extras.middleware.ChangeLoggingMiddleware.
         by extras.middleware.ChangeLoggingMiddleware.
         """
         """
-        ObjectChange(
-            user=user,
-            request_id=request_id,
+        return ObjectChange(
             changed_object=self,
             changed_object=self,
+            object_repr=str(self),
             action=action,
             action=action,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
-        ).save()
+        )

+ 8 - 8
netbox/virtualization/api/views.py

@@ -40,10 +40,8 @@ class ClusterGroupViewSet(ModelViewSet):
 
 
 
 
 class ClusterViewSet(CustomFieldModelViewSet):
 class ClusterViewSet(CustomFieldModelViewSet):
-    queryset = Cluster.objects.select_related(
-        'type', 'group', 'site',
-    ).prefetch_related(
-        'tags'
+    queryset = Cluster.objects.prefetch_related(
+        'type', 'group', 'site', 'tags'
     ).annotate(
     ).annotate(
         device_count=get_subquery(Device, 'cluster'),
         device_count=get_subquery(Device, 'cluster'),
         virtualmachine_count=get_subquery(VirtualMachine, 'cluster')
         virtualmachine_count=get_subquery(VirtualMachine, 'cluster')
@@ -57,9 +55,9 @@ class ClusterViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class VirtualMachineViewSet(CustomFieldModelViewSet):
 class VirtualMachineViewSet(CustomFieldModelViewSet):
-    queryset = VirtualMachine.objects.select_related(
-        'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6'
-    ).prefetch_related('tags')
+    queryset = VirtualMachine.objects.prefetch_related(
+        'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
+    )
     filterset_class = filters.VirtualMachineFilter
     filterset_class = filters.VirtualMachineFilter
 
 
     def get_serializer_class(self):
     def get_serializer_class(self):
@@ -86,7 +84,9 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
 class InterfaceViewSet(ModelViewSet):
 class InterfaceViewSet(ModelViewSet):
     queryset = Interface.objects.filter(
     queryset = Interface.objects.filter(
         virtual_machine__isnull=False
         virtual_machine__isnull=False
-    ).select_related('virtual_machine').prefetch_related('tags')
+    ).prefetch_related(
+        'virtual_machine', 'tags'
+    )
     serializer_class = serializers.InterfaceSerializer
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filters.InterfaceFilter
     filterset_class = filters.InterfaceFilter
 
 

+ 7 - 1
netbox/virtualization/filters.py

@@ -6,7 +6,9 @@ from netaddr.core import AddrFormatError
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
-from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
+from utilities.filters import (
+    MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
+)
 from .constants import VM_STATUS_CHOICES
 from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
@@ -160,6 +162,10 @@ class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Platform (slug)',
         label='Platform (slug)',
     )
     )
+    mac_address = MultiValueMACAddressFilter(
+        field_name='interfaces__mac_address',
+        label='MAC address',
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:

+ 7 - 3
netbox/virtualization/forms.py

@@ -376,7 +376,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             for family in [4, 6]:
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
                 ip_choices = [(None, '---------')]
                 # Collect interface IPs
                 # Collect interface IPs
-                interface_ips = IPAddress.objects.select_related('interface').filter(
+                interface_ips = IPAddress.objects.prefetch_related('interface').filter(
                     family=family, interface__virtual_machine=self.instance
                     family=family, interface__virtual_machine=self.instance
                 )
                 )
                 if interface_ips:
                 if interface_ips:
@@ -386,7 +386,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
                         ])
                         ])
                     )
                     )
                 # Collect NAT IPs
                 # Collect NAT IPs
-                nat_ips = IPAddress.objects.select_related('nat_inside').filter(
+                nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                     family=family, nat_inside__interface__virtual_machine=self.instance
                     family=family, nat_inside__interface__virtual_machine=self.instance
                 )
                 )
                 if nat_ips:
                 if nat_ips:
@@ -525,7 +525,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
     model = VirtualMachine
     model = VirtualMachine
     field_order = [
     field_order = [
         'q', 'cluster_group', 'cluster_type', 'cluster_id', 'status', 'role', 'region', 'site', 'tenant_group',
         'q', 'cluster_group', 'cluster_type', 'cluster_id', 'status', 'role', 'region', 'site', 'tenant_group',
-        'tenant', 'platform',
+        'tenant', 'platform', 'mac_address',
     ]
     ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
@@ -606,6 +606,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
             null_option=True,
             null_option=True,
         )
         )
     )
     )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC address'
+    )
 
 
 
 
 #
 #

+ 8 - 8
netbox/virtualization/views.py

@@ -96,7 +96,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class ClusterListView(PermissionRequiredMixin, ObjectListView):
 class ClusterListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'virtualization.view_cluster'
     permission_required = 'virtualization.view_cluster'
-    queryset = Cluster.objects.select_related('type', 'group', 'site')
+    queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
     table = tables.ClusterTable
     table = tables.ClusterTable
     filter = filters.ClusterFilter
     filter = filters.ClusterFilter
     filter_form = forms.ClusterFilterForm
     filter_form = forms.ClusterFilterForm
@@ -109,7 +109,7 @@ class ClusterView(PermissionRequiredMixin, View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         cluster = get_object_or_404(Cluster, pk=pk)
         cluster = get_object_or_404(Cluster, pk=pk)
-        devices = Device.objects.filter(cluster=cluster).select_related(
+        devices = Device.objects.filter(cluster=cluster).prefetch_related(
             'site', 'rack', 'tenant', 'device_type__manufacturer'
             'site', 'rack', 'tenant', 'device_type__manufacturer'
         )
         )
         device_table = DeviceTable(list(devices), orderable=False)
         device_table = DeviceTable(list(devices), orderable=False)
@@ -148,7 +148,7 @@ class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'virtualization.change_cluster'
     permission_required = 'virtualization.change_cluster'
-    queryset = Cluster.objects.select_related('type', 'group', 'site')
+    queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
     filter = filters.ClusterFilter
     filter = filters.ClusterFilter
     table = tables.ClusterTable
     table = tables.ClusterTable
     form = forms.ClusterBulkEditForm
     form = forms.ClusterBulkEditForm
@@ -157,7 +157,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'virtualization.delete_cluster'
     permission_required = 'virtualization.delete_cluster'
-    queryset = Cluster.objects.select_related('type', 'group', 'site')
+    queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
     filter = filters.ClusterFilter
     filter = filters.ClusterFilter
     table = tables.ClusterTable
     table = tables.ClusterTable
     default_return_url = 'virtualization:cluster_list'
     default_return_url = 'virtualization:cluster_list'
@@ -253,7 +253,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
 
 
 class VirtualMachineListView(PermissionRequiredMixin, ObjectListView):
 class VirtualMachineListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'virtualization.view_virtualmachine'
     permission_required = 'virtualization.view_virtualmachine'
-    queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
+    queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
     filter = filters.VirtualMachineFilter
     filter = filters.VirtualMachineFilter
     filter_form = forms.VirtualMachineFilterForm
     filter_form = forms.VirtualMachineFilterForm
     table = tables.VirtualMachineDetailTable
     table = tables.VirtualMachineDetailTable
@@ -265,7 +265,7 @@ class VirtualMachineView(PermissionRequiredMixin, View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        virtualmachine = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
+        virtualmachine = get_object_or_404(VirtualMachine.objects.prefetch_related('tenant__group'), pk=pk)
         interfaces = Interface.objects.filter(virtual_machine=virtualmachine)
         interfaces = Interface.objects.filter(virtual_machine=virtualmachine)
         services = Service.objects.filter(virtual_machine=virtualmachine)
         services = Service.objects.filter(virtual_machine=virtualmachine)
 
 
@@ -309,7 +309,7 @@ class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 
 class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
 class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'virtualization.change_virtualmachine'
     permission_required = 'virtualization.change_virtualmachine'
-    queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role')
+    queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
     filter = filters.VirtualMachineFilter
     filter = filters.VirtualMachineFilter
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     form = forms.VirtualMachineBulkEditForm
     form = forms.VirtualMachineBulkEditForm
@@ -318,7 +318,7 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 
 class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'virtualization.delete_virtualmachine'
     permission_required = 'virtualization.delete_virtualmachine'
-    queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role')
+    queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
     filter = filters.VirtualMachineFilter
     filter = filters.VirtualMachineFilter
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     default_return_url = 'virtualization:virtualmachine_list'
     default_return_url = 'virtualization:virtualmachine_list'

Неке датотеке нису приказане због велике количине промена