Forráskód Böngészése

Merge branch 'develop' into develop-2.10

Jeremy Stretch 5 éve
szülő
commit
96650b0216
36 módosított fájl, 255 hozzáadás és 85 törlés
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.md
  2. 1 1
      .github/ISSUE_TEMPLATE/config.yml
  3. 3 3
      .github/ISSUE_TEMPLATE/feature_request.md
  4. 2 2
      CONTRIBUTING.md
  5. 2 2
      README.md
  6. 12 0
      docs/additional-features/custom-links.md
  7. 24 0
      docs/additional-features/custom-scripts.md
  8. 6 3
      docs/additional-features/reports.md
  9. 1 1
      docs/development/index.md
  10. 0 4
      docs/development/release-checklist.md
  11. BIN
      docs/media/admin_ui_run_permission.png
  12. 3 0
      docs/plugins/development.md
  13. 34 0
      docs/release-notes/version-2.9.md
  14. 2 3
      netbox/circuits/views.py
  15. 3 9
      netbox/dcim/filters.py
  16. 18 0
      netbox/dcim/forms.py
  17. 25 8
      netbox/dcim/models/device_components.py
  18. 2 3
      netbox/dcim/views.py
  19. 4 3
      netbox/extras/models/customfields.py
  20. 1 3
      netbox/extras/tables.py
  21. 12 7
      netbox/extras/templatetags/custom_links.py
  22. 2 3
      netbox/extras/views.py
  23. 2 1
      netbox/ipam/api/serializers.py
  24. 12 9
      netbox/ipam/views.py
  25. 1 1
      netbox/netbox/authentication.py
  26. 1 1
      netbox/templates/500.html
  27. 0 10
      netbox/templates/dcim/inc/rack_elevation_header.html
  28. 16 3
      netbox/templates/dcim/rack_elevation_list.html
  29. 19 1
      netbox/templates/ipam/ipaddress.html
  30. 1 1
      netbox/utilities/management/commands/makemigrations.py
  31. 2 1
      netbox/utilities/tables.py
  32. 1 1
      netbox/utilities/views.py
  33. 10 0
      netbox/virtualization/filters.py
  34. 7 0
      netbox/virtualization/forms.py
  35. 16 0
      netbox/virtualization/tests/test_filters.py
  36. 9 0
      scripts/git-hooks/pre-commit

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.md

@@ -11,7 +11,7 @@ about: Report a reproducible bug in the current release of NetBox
     NetBox installation, or if you have a general question, DO NOT open an
     NetBox installation, or if you have a general question, DO NOT open an
     issue. Instead, post to our mailing list:
     issue. Instead, post to our mailing list:
 
 
-        https://groups.google.com/forum/#!forum/netbox-discuss
+        https://groups.google.com/g/netbox-discuss
 
 
     Please describe the environment in which you are running NetBox. Be sure
     Please describe the environment in which you are running NetBox. Be sure
     that you are running an unmodified instance of the latest stable release
     that you are running an unmodified instance of the latest stable release

+ 1 - 1
.github/ISSUE_TEMPLATE/config.yml

@@ -5,5 +5,5 @@ contact_links:
     url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
     url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
     about: Please read through our contributing policy before opening an issue or pull request
     about: Please read through our contributing policy before opening an issue or pull request
   - name: 💬 Discussion Group
   - name: 💬 Discussion Group
-    url: https://groups.google.com/forum/#!forum/netbox-discuss
+    url: https://groups.google.com/g/netbox-discuss
     about: Join our discussion group for assistance with installation issues and other problems
     about: Join our discussion group for assistance with installation issues and other problems

+ 3 - 3
.github/ISSUE_TEMPLATE/feature_request.md

@@ -11,7 +11,7 @@ about: Propose a new NetBox feature or enhancement
     If you have a general idea or question, please post to our mailing list
     If you have a general idea or question, please post to our mailing list
     instead of opening an issue:
     instead of opening an issue:
 
 
-        https://groups.google.com/forum/#!forum/netbox-discuss
+        https://groups.google.com/g/netbox-discuss
 
 
     NOTE: Due to an excessive backlog of feature requests, we are not currently
     NOTE: Due to an excessive backlog of feature requests, we are not currently
     accepting any proposals which significantly extend NetBox's feature scope.
     accepting any proposals which significantly extend NetBox's feature scope.
@@ -21,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
     before submitting a bug report.
     before submitting a bug report.
 -->
 -->
 ### Environment
 ### Environment
-* Python version:  <!-- Example: 3.6.9 -->
-* NetBox version:  <!-- Example: 2.7.3 -->
+* Python version: 
+* NetBox version: 
 
 
 <!--
 <!--
     Describe in detail the new functionality you are proposing. Include any
     Describe in detail the new functionality you are proposing. Include any

+ 2 - 2
CONTRIBUTING.md

@@ -8,7 +8,7 @@ except to report bugs or request features.
 
 
 We have established a Google Groups Mailing List for issues and general
 We have established a Google Groups Mailing List for issues and general
 discussion. This is the best forum for obtaining assistance with NetBox
 discussion. This is the best forum for obtaining assistance with NetBox
-installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
+installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
 
 
 ### Slack
 ### Slack
 
 
@@ -164,7 +164,7 @@ overlooked.
 * Official channels for communication include:
 * Official channels for communication include:
 
 
     * GitHub issues/pull requests
     * GitHub issues/pull requests
-    * The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
+    * The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
     * The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
     * The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
 
 
 * Maintainers with no substantial recorded activity in a 60-day period will be
 * Maintainers with no substantial recorded activity in a 60-day period will be

+ 2 - 2
README.md

@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
 
 
 The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
 The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
 
 
-Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
+Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
 or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
 or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
 
 
 ### Build Status
 ### Build Status
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
 
 
 Feature requests and bug reports must be submitted as GiHub issues. (Please be
 Feature requests and bug reports must be submitted as GiHub issues. (Please be
 sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
 sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
-For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
+For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
 
 
 If you are interested in contributing to the development of NetBox, please read
 If you are interested in contributing to the development of NetBox, please read
 our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
 our [contributing guide](CONTRIBUTING.md) prior to beginning any work.

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

@@ -17,6 +17,18 @@ When viewing a device named Router4, this link would render as:
 
 
 Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
 Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
 
 
+## Context Data
+
+The following context data is available within the template when rendering a custom link's text or URL.
+
+| Variable | Description |
+|----------|-------------|
+| `obj`      | The NetBox object being displayed |
+| `debug`    | A boolean indicating whether debugging is enabled |
+| `request`  | The current WSGI request |
+| `user`     | The current user (if authenticated) |
+| `perms`    | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
+
 ## Conditional Rendering
 ## 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.
 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.

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

@@ -231,6 +231,30 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
 * `min_prefix_length` - Minimum length of the mask
 * `min_prefix_length` - Minimum length of the mask
 * `max_prefix_length` - Maximum length of the mask
 * `max_prefix_length` - Maximum length of the mask
 
 
+## Running Custom Scripts
+
+!!! note
+    To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
+
+    ![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
+
+### Via the Web UI
+
+Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
+
+### Via the API
+
+To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
+
+```no-highlight
+curl -X POST \
+-H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json; indent=4" \
+http://netbox/api/extras/scripts/example.MyReport/ \
+--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
+```
+
 ## Example
 ## Example
 
 
 Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
 Below is an example script that creates new objects for a planned site. The user is prompted for three variables:

+ 6 - 3
docs/additional-features/reports.md

@@ -101,11 +101,14 @@ Once you have created a report, it will appear in the reports list. Initially, r
 
 
 ## Running Reports
 ## Running Reports
 
 
-### Via the Web UI
+!!! note
+    To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
+
+    ![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
 
 
-Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
+### Via the Web UI
 
 
-Once a report has been run, its associated results will be included in the report view.
+Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
 
 
 ### Via the API
 ### Via the API
 
 

+ 1 - 1
docs/development/index.md

@@ -7,7 +7,7 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
 Communication among developers should always occur via public channels:
 Communication among developers should always occur via public channels:
 
 
 * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
 * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
-* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
+* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
 * [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
 * [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
 
 
 ## Governance
 ## Governance

+ 0 - 4
docs/development/release-checklist.md

@@ -89,7 +89,3 @@ On the `develop` branch, update `VERSION` in `settings.py` to point to the next
 ```
 ```
 VERSION = 'v2.3.5-dev'
 VERSION = 'v2.3.5-dev'
 ```
 ```
-
-### Announce the Release
-
-Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes.

BIN
docs/media/admin_ui_run_permission.png


+ 3 - 0
docs/plugins/development.md

@@ -12,6 +12,9 @@ Plugins can do a lot, including:
 
 
 However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
 However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
 
 
+!!! warning
+    While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases. 
+
 ## Initial Setup
 ## Initial Setup
 
 
 ## Plugin Structure
 ## Plugin Structure

+ 34 - 0
docs/release-notes/version-2.9.md

@@ -1,5 +1,39 @@
 # NetBox v2.9
 # NetBox v2.9
 
 
+## v2.9.6 (2020-10-09)
+
+### Bug Fixes
+
+* [#5229](https://github.com/netbox-community/netbox/issues/5229) - Fix AttributeError exception when LDAP authentication is enabled
+
+---
+
+## v2.9.5 (2020-10-09)
+
+### Enhancements
+
+* [#5202](https://github.com/netbox-community/netbox/issues/5202) - Extend the available context data when rendering custom links
+
+### Bug Fixes
+
+* [#4523](https://github.com/netbox-community/netbox/issues/4523) - Populate site vlan list when bulk editing interfaces under certain circumstances
+* [#5174](https://github.com/netbox-community/netbox/issues/5174) - Ensure consistent alignment of rack elevations
+* [#5175](https://github.com/netbox-community/netbox/issues/5175) - Fix toggling of rack elevation order
+* [#5184](https://github.com/netbox-community/netbox/issues/5184) - Fix missing Power Utilization
+* [#5197](https://github.com/netbox-community/netbox/issues/5197) - Limit duplicate IPs shown on IP address view
+* [#5199](https://github.com/netbox-community/netbox/issues/5199) - Change default LDAP logging to INFO
+* [#5201](https://github.com/netbox-community/netbox/issues/5201) - Fix missing querystring when bulk editing/deleting VLAN Group VLANs when selecting "select all x items matching query"
+* [#5206](https://github.com/netbox-community/netbox/issues/5206) - Apply user pagination preferences to all paginated object lists
+* [#5211](https://github.com/netbox-community/netbox/issues/5211) - Add missing `has_primary_ip` filter for virtual machines
+* [#5217](https://github.com/netbox-community/netbox/issues/5217) - Prevent erroneous removal of prefetched GenericForeignKey data from tables
+* [#5218](https://github.com/netbox-community/netbox/issues/5218) - Raise validation error if a power port's `allocated_draw` exceeds its `maximum_draw`
+* [#5220](https://github.com/netbox-community/netbox/issues/5220) - Fix API patch request against IP Address endpoint with null assigned_object_type 
+* [#5221](https://github.com/netbox-community/netbox/issues/5221) - Fix bulk component creation for virtual machines
+* [#5224](https://github.com/netbox-community/netbox/issues/5224) - Don't allow a rear port to have fewer positions than the number of mapped front ports
+* [#5226](https://github.com/netbox-community/netbox/issues/5226) - Custom choice fields should be blank initially if no default choice has been designated
+
+---
+
 ## v2.9.4 (2020-09-23)
 ## v2.9.4 (2020-09-23)
 
 
 **NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
 **NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.

+ 2 - 3
netbox/circuits/views.py

@@ -1,4 +1,3 @@
-from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.db import transaction
 from django.db import transaction
 from django.db.models import Count
 from django.db.models import Count
@@ -6,7 +5,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django_tables2 import RequestConfig
 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, get_paginate_count
 from utilities.views import (
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
@@ -43,7 +42,7 @@ class ProviderView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(circuits_table)
         RequestConfig(request, paginate).configure(circuits_table)
 
 

+ 3 - 9
netbox/dcim/filters.py

@@ -665,16 +665,10 @@ class DeviceFilterSet(
         ).distinct()
         ).distinct()
 
 
     def _has_primary_ip(self, queryset, name, value):
     def _has_primary_ip(self, queryset, name, value):
+        params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
         if value:
         if value:
-            return queryset.filter(
-                Q(primary_ip4__isnull=False) |
-                Q(primary_ip6__isnull=False)
-            )
-        else:
-            return queryset.exclude(
-                Q(primary_ip4__isnull=False) |
-                Q(primary_ip6__isnull=False)
-            )
+            return queryset.filter(params)
+        return queryset.exclude(params)
 
 
     def _virtual_chassis_member(self, queryset, name, value):
     def _virtual_chassis_member(self, queryset, name, value):
         return queryset.exclude(virtual_chassis__isnull=value)
         return queryset.exclude(virtual_chassis__isnull=value)

+ 18 - 0
netbox/dcim/forms.py

@@ -2844,6 +2844,24 @@ class InterfaceBulkEditForm(
             self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
             self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
             self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
             self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
         else:
         else:
+            # See 4523
+            if 'pk' in self.initial:
+                site = None
+                interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
+
+                # Check interface sites.  First interface should set site, further interfaces will either continue the
+                # loop or reset back to no site and break the loop.
+                for interface in interfaces:
+                    if site is None:
+                        site = interface.device.site
+                    elif interface.device.site is not site:
+                        site = None
+                        break
+
+                if site is not None:
+                    self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
+                    self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
+
             self.fields['lag'].choices = ()
             self.fields['lag'].choices = ()
             self.fields['lag'].widget.attrs['disabled'] = True
             self.fields['lag'].widget.attrs['disabled'] = True
 
 

+ 25 - 8
netbox/dcim/models/device_components.py

@@ -316,6 +316,14 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
             self.description,
             self.description,
         )
         )
 
 
+    def clean(self):
+
+        if self.maximum_draw is not None and self.allocated_draw is not None:
+            if self.allocated_draw > self.maximum_draw:
+                raise ValidationError({
+                    'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
+                })
+
     def get_power_draw(self):
     def get_power_draw(self):
         """
         """
         Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
         Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
@@ -664,17 +672,16 @@ class FrontPort(CableTermination, ComponentModel):
 
 
         # Validate rear port assignment
         # Validate rear port assignment
         if self.rear_port.device != self.device:
         if self.rear_port.device != self.device:
-            raise ValidationError(
-                "Rear port ({}) must belong to the same device".format(self.rear_port)
-            )
+            raise ValidationError({
+                "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
+            })
 
 
         # Validate rear port position assignment
         # Validate rear port position assignment
         if self.rear_port_position > self.rear_port.positions:
         if self.rear_port_position > self.rear_port.positions:
-            raise ValidationError(
-                "Invalid rear port position ({}); rear port {} has only {} positions".format(
-                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
-                )
-            )
+            raise ValidationError({
+                "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
+                                      f"{self.rear_port.name} has only {self.rear_port.positions} positions"
+            })
 
 
 
 
 @extras_features('webhooks')
 @extras_features('webhooks')
@@ -704,6 +711,16 @@ class RearPort(CableTermination, ComponentModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:rearport', kwargs={'pk': self.pk})
         return reverse('dcim:rearport', kwargs={'pk': self.pk})
 
 
+    def clean(self):
+
+        # Check that positions count is greater than or equal to the number of associated FrontPorts
+        frontport_count = self.frontports.count()
+        if self.positions < frontport_count:
+            raise ValidationError({
+                "positions": f"The number of positions cannot be less than the number of mapped front ports "
+                             f"({frontport_count})"
+            })
+
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.device.identifier,
             self.device.identifier,

+ 2 - 3
netbox/dcim/views.py

@@ -1,6 +1,5 @@
 from collections import OrderedDict
 from collections import OrderedDict
 
 
-from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -19,7 +18,7 @@ from ipam.models import IPAddress, Prefix, Service, VLAN
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from secrets.models import Secret
 from secrets.models import Secret
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
-from utilities.paginator import EnhancedPaginator
+from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.utils import csv_format, get_subquery
 from utilities.utils import csv_format, get_subquery
 from utilities.views import (
 from utilities.views import (
@@ -317,7 +316,7 @@ class RackElevationListView(ObjectListView):
             racks = racks.reverse()
             racks = racks.reverse()
 
 
         # Pagination
         # Pagination
-        per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
+        per_page = get_paginate_count(request)
         page_number = request.GET.get('page', 1)
         page_number = request.GET.get('page', 1)
         paginator = EnhancedPaginator(racks, per_page)
         paginator = EnhancedPaginator(racks, per_page)
         try:
         try:

+ 4 - 3
netbox/extras/models/customfields.py

@@ -175,13 +175,14 @@ class CustomField(models.Model):
         # Select
         # Select
         elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
         elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
             choices = [(c, c) for c in self.choices]
             choices = [(c, c) for c in self.choices]
+            default_choice = self.default if self.default in self.choices else None
 
 
-            if not required:
+            if not required or default_choice is None:
                 choices = add_blank_choice(choices)
                 choices = add_blank_choice(choices)
 
 
             # Set the initial value to the first available choice (if any)
             # Set the initial value to the first available choice (if any)
-            if set_initial and self.choices:
-                initial = self.choices[0]
+            if set_initial and default_choice:
+                initial = default_choice
 
 
             field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
             field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
             field = field_class(
             field = field_class(

+ 1 - 3
netbox/extras/tables.py

@@ -22,10 +22,8 @@ CONFIGCONTEXT_ACTIONS = """
 """
 """
 
 
 OBJECTCHANGE_OBJECT = """
 OBJECTCHANGE_OBJECT = """
-{% if record.action != 3 and record.changed_object.get_absolute_url %}
+{% if record.changed_object.get_absolute_url %}
     <a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
     <a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
-{% elif record.action != 3 and record.related_object.get_absolute_url %}
-    <a href="{{ record.related_object.get_absolute_url }}">{{ record.object_repr }}</a>
 {% else %}
 {% else %}
     {{ record.object_repr }}
     {{ record.object_repr }}
 {% endif %}
 {% endif %}

+ 12 - 7
netbox/extras/templatetags/custom_links.py

@@ -20,8 +20,8 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
 GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
 GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
 
 
 
 
-@register.simple_tag()
-def custom_links(obj):
+@register.simple_tag(takes_context=True)
+def custom_links(context, obj):
     """
     """
     Render all applicable links for the given object.
     Render all applicable links for the given object.
     """
     """
@@ -30,8 +30,13 @@ def custom_links(obj):
     if not custom_links:
     if not custom_links:
         return ''
         return ''
 
 
-    context = {
+    # Pass select context data when rendering the CustomLink
+    link_context = {
         'obj': obj,
         'obj': obj,
+        'debug': context['debug'],  # django.template.context_processors.debug
+        'request': context['request'],  # django.template.context_processors.request
+        'user': context['user'],  # django.contrib.auth.context_processors.auth
+        'perms': context['perms'],  # django.contrib.auth.context_processors.auth
     }
     }
     template_code = ''
     template_code = ''
     group_names = OrderedDict()
     group_names = OrderedDict()
@@ -47,9 +52,9 @@ def custom_links(obj):
         # Add non-grouped links
         # Add non-grouped links
         else:
         else:
             try:
             try:
-                text_rendered = render_jinja2(cl.text, context)
+                text_rendered = render_jinja2(cl.text, link_context)
                 if text_rendered:
                 if text_rendered:
-                    link_rendered = render_jinja2(cl.url, context)
+                    link_rendered = render_jinja2(cl.url, link_context)
                     link_target = ' target="_blank"' if cl.new_window else ''
                     link_target = ' target="_blank"' if cl.new_window else ''
                     template_code += LINK_BUTTON.format(
                     template_code += LINK_BUTTON.format(
                         link_rendered, link_target, cl.button_class, text_rendered
                         link_rendered, link_target, cl.button_class, text_rendered
@@ -65,10 +70,10 @@ def custom_links(obj):
 
 
         for cl in links:
         for cl in links:
             try:
             try:
-                text_rendered = render_jinja2(cl.text, context)
+                text_rendered = render_jinja2(cl.text, link_context)
                 if text_rendered:
                 if text_rendered:
                     link_target = ' target="_blank"' if cl.new_window else ''
                     link_target = ' target="_blank"' if cl.new_window else ''
-                    link_rendered = render_jinja2(cl.url, context)
+                    link_rendered = render_jinja2(cl.url, link_context)
                     links_rendered.append(
                     links_rendered.append(
                         GROUP_LINK.format(link_rendered, link_target, text_rendered)
                         GROUP_LINK.format(link_rendered, link_target, text_rendered)
                     )
                     )

+ 2 - 3
netbox/extras/views.py

@@ -1,5 +1,4 @@
 from django import template
 from django import template
-from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, Prefetch, Q
 from django.db.models import Count, Prefetch, Q
@@ -13,7 +12,7 @@ from rq import Worker
 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.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
-from utilities.paginator import EnhancedPaginator
+from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.utils import copy_safe_request, shallow_compare_dict
 from utilities.utils import copy_safe_request, shallow_compare_dict
 from utilities.views import (
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -258,7 +257,7 @@ class ObjectChangeLogView(View):
         # Apply the request context
         # Apply the request context
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(objectchanges_table)
         RequestConfig(request, paginate).configure(objectchanges_table)
 
 

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

@@ -233,7 +233,8 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
     role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
     assigned_object_type = ContentTypeField(
     assigned_object_type = ContentTypeField(
         queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
         queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
-        required=False
+        required=False,
+        allow_null=True
     )
     )
     assigned_object = serializers.SerializerMethodField(read_only=True)
     assigned_object = serializers.SerializerMethodField(read_only=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)

+ 12 - 9
netbox/ipam/views.py

@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 
 
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
-from utilities.paginator import EnhancedPaginator
+from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.utils import get_subquery
 from utilities.utils import get_subquery
 from utilities.views import (
 from utilities.views import (
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
@@ -305,7 +305,7 @@ class AggregateView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(prefix_table)
         RequestConfig(request, paginate).configure(prefix_table)
 
 
@@ -463,7 +463,7 @@ class PrefixPrefixesView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(prefix_table)
         RequestConfig(request, paginate).configure(prefix_table)
 
 
@@ -507,7 +507,7 @@ class PrefixIPAddressesView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(ip_table)
         RequestConfig(request, paginate).configure(ip_table)
 
 
@@ -599,7 +599,8 @@ class IPAddressView(ObjectView):
         # Exclude anycast IPs if this IP is anycast
         # Exclude anycast IPs if this IP is anycast
         if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
         if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
             duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
             duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
-        duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
+        # Limit to a maximum of 10 duplicates displayed here
+        duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
 
 
         # Related IP table
         # Related IP table
         related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
         related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
@@ -611,7 +612,7 @@ class IPAddressView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(related_ips_table)
         RequestConfig(request, paginate).configure(related_ips_table)
 
 
@@ -619,6 +620,7 @@ class IPAddressView(ObjectView):
             'ipaddress': ipaddress,
             'ipaddress': ipaddress,
             'parent_prefixes_table': parent_prefixes_table,
             'parent_prefixes_table': parent_prefixes_table,
             'duplicate_ips_table': duplicate_ips_table,
             'duplicate_ips_table': duplicate_ips_table,
+            'more_duplicate_ips': duplicate_ips.count() > 10,
             'related_ips_table': related_ips_table,
             'related_ips_table': related_ips_table,
         })
         })
 
 
@@ -771,7 +773,7 @@ class VLANGroupVLANsView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request),
         }
         }
         RequestConfig(request, paginate).configure(vlan_table)
         RequestConfig(request, paginate).configure(vlan_table)
 
 
@@ -785,6 +787,7 @@ class VLANGroupVLANsView(ObjectView):
         return render(request, 'ipam/vlangroup_vlans.html', {
         return render(request, 'ipam/vlangroup_vlans.html', {
             'vlan_group': vlan_group,
             'vlan_group': vlan_group,
             'first_available_vlan': vlan_group.get_next_available_vid(),
             'first_available_vlan': vlan_group.get_next_available_vid(),
+            'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
             'vlan_table': vlan_table,
             'vlan_table': vlan_table,
             'permissions': permissions,
             'permissions': permissions,
         })
         })
@@ -831,7 +834,7 @@ class VLANInterfacesView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(members_table)
         RequestConfig(request, paginate).configure(members_table)
 
 
@@ -852,7 +855,7 @@ class VLANVMInterfacesView(ObjectView):
 
 
         paginate = {
         paginate = {
             'paginator_class': EnhancedPaginator,
             'paginator_class': EnhancedPaginator,
-            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+            'per_page': get_paginate_count(request)
         }
         }
         RequestConfig(request, paginate).configure(members_table)
         RequestConfig(request, paginate).configure(members_table)
 
 

+ 1 - 1
netbox/netbox/authentication.py

@@ -175,6 +175,6 @@ class LDAPBackend:
         # Enable logging for django_auth_ldap
         # Enable logging for django_auth_ldap
         ldap_logger = logging.getLogger('django_auth_ldap')
         ldap_logger = logging.getLogger('django_auth_ldap')
         ldap_logger.addHandler(logging.StreamHandler())
         ldap_logger.addHandler(logging.StreamHandler())
-        ldap_logger.setLevel(logging.DEBUG)
+        ldap_logger.setLevel(logging.INFO)
 
 
         return obj
         return obj

+ 1 - 1
netbox/templates/500.html

@@ -36,7 +36,7 @@
 Python version: {{ python_version }}
 Python version: {{ python_version }}
 NetBox version: {{ netbox_version }}</pre>
 NetBox version: {{ netbox_version }}</pre>
                         <p>
                         <p>
-                            If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
+                            If further assistance is required, please post to the <a href="https://groups.google.com/g/netbox-discuss">NetBox mailing list</a>.
                         </p>
                         </p>
                         <div class="text-right">
                         <div class="text-right">
                             <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
                             <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>

+ 0 - 10
netbox/templates/dcim/inc/rack_elevation_header.html

@@ -1,10 +0,0 @@
-{% load helpers %}
-<div class="rack_header">
-    <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
-    {% if rack.role %}
-        <br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
-    {% endif %}
-    {% if rack.facility_id %}
-        <br /><small class="text-muted">{{ rack.facility_id }}</small>
-    {% endif %}
-</div>

+ 16 - 3
netbox/templates/dcim/rack_elevation_list.html

@@ -12,7 +12,7 @@
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
     </div>
     </div>
     <div class="btn-group" role="group">
     <div class="btn-group" role="group">
-        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
+        <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse=None %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
     </div>
     </div>
 </div>
 </div>
@@ -23,10 +23,23 @@
             <div style="white-space: nowrap; overflow-x: scroll;">
             <div style="white-space: nowrap; overflow-x: scroll;">
                 {% for rack in page %}
                 {% for rack in page %}
                     <div style="display: inline-block; width: 266px">
                     <div style="display: inline-block; width: 266px">
-                        {% include 'dcim/inc/rack_elevation_header.html' %}
+                        <div class="text-center">
+                            <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
+                            {% if rack.role %}
+                                <br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
+                            {% endif %}
+                            {% if rack.facility_id %}
+                                <br /><small class="text-muted">{{ rack.facility_id }}</small>
+                            {% endif %}
+                        </div>
                         {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
                         {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
                         <div class="clearfix"></div>
                         <div class="clearfix"></div>
-                        {% include 'dcim/inc/rack_elevation_header.html' %}
+                        <div class="text-center">
+                            <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
+                            {% if rack.facility_id %}
+                                <small class="text-muted">({{ rack.facility_id }})</small>
+                            {% endif %}
+                        </div>
                     </div>
                     </div>
                 {% endfor %}
                 {% endfor %}
             </div>
             </div>

+ 19 - 1
netbox/templates/ipam/ipaddress.html

@@ -3,6 +3,7 @@
 {% load custom_links %}
 {% load custom_links %}
 {% load helpers %}
 {% load helpers %}
 {% load plugins %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 
 {% block header %}
 {% block header %}
     <div class="row noprint">
     <div class="row noprint">
@@ -159,7 +160,24 @@
 	<div class="col-md-8">
 	<div class="col-md-8">
         {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
         {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
         {% if duplicate_ips_table.rows %}
         {% if duplicate_ips_table.rows %}
-            {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
+            {# Custom version of panel_table.html #}
+            <div class="panel panel-danger">
+                <div class="panel-heading">
+                    <strong>Duplicate IP Addresses</strong>
+                    {% if more_duplicate_ips %}
+                    <div class="pull-right">
+                        <a type="button" class="btn btn-primary btn-xs"
+                        {% if ipaddress.vrf %}
+                        href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id={{ ipaddress.vrf.pk }}"
+                        {% else %}
+                        href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id=null"
+                        {% endif %}
+                        >Show all</a>
+                    </div>
+                    {% endif %}
+                </div>
+                {% render_table duplicate_ips_table 'inc/table.html' %}
+            </div>
         {% endif %}
         {% endif %}
         {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
         {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
         {% plugin_right_page ipaddress %}
         {% plugin_right_page ipaddress %}

+ 1 - 1
netbox/utilities/management/commands/makemigrations.py

@@ -22,7 +22,7 @@ class Command(_Command):
                 "This command is available for development purposes only. It will\n"
                 "This command is available for development purposes only. It will\n"
                 "NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
                 "NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
                 "please post to the NetBox mailing list:\n"
                 "please post to the NetBox mailing list:\n"
-                "    https://groups.google.com/forum/#!forum/netbox-discuss"
+                "    https://groups.google.com/g/netbox-discuss"
             )
             )
 
 
         super().handle(*args, **kwargs)
         super().handle(*args, **kwargs)

+ 2 - 1
netbox/utilities/tables.py

@@ -1,4 +1,5 @@
 import django_tables2 as tables
 import django_tables2 as tables
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
 from django.urls import reverse
@@ -64,7 +65,7 @@ class BaseTable(tables.Table):
                     field_path = column.accessor.split('.')
                     field_path = column.accessor.split('.')
                     try:
                     try:
                         model_field = model._meta.get_field(field_path[0])
                         model_field = model._meta.get_field(field_path[0])
-                        if isinstance(model_field, RelatedField):
+                        if isinstance(model_field, (RelatedField, GenericForeignKey)):
                             prefetch_fields.append('__'.join(field_path))
                             prefetch_fields.append('__'.join(field_path))
                     except FieldDoesNotExist:
                     except FieldDoesNotExist:
                         pass
                         pass

+ 1 - 1
netbox/utilities/views.py

@@ -1323,7 +1323,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
                         for obj in data['pk']:
                         for obj in data['pk']:
 
 
                             names = data['name_pattern']
                             names = data['name_pattern']
-                            labels = data['label_pattern']
+                            labels = data['label_pattern'] if 'label_pattern' in data else None
                             for i, name in enumerate(names):
                             for i, name in enumerate(names):
                                 label = labels[i] if labels else None
                                 label = labels[i] if labels else None
 
 

+ 10 - 0
netbox/virtualization/filters.py

@@ -186,6 +186,10 @@ class VirtualMachineFilterSet(
         field_name='interfaces__mac_address',
         field_name='interfaces__mac_address',
         label='MAC address',
         label='MAC address',
     )
     )
+    has_primary_ip = django_filters.BooleanFilter(
+        method='_has_primary_ip',
+        label='Has a primary IP',
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
@@ -200,6 +204,12 @@ class VirtualMachineFilterSet(
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
 
 
+    def _has_primary_ip(self, queryset, name, value):
+        params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
+        if value:
+            return queryset.filter(params)
+        return queryset.exclude(params)
+
 
 
 class VMInterfaceFilterSet(BaseFilterSet):
 class VMInterfaceFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(

+ 7 - 0
netbox/virtualization/forms.py

@@ -516,6 +516,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         required=False,
         required=False,
         label='MAC address'
         label='MAC address'
     )
     )
+    has_primary_ip = forms.NullBooleanField(
+        required=False,
+        label='Has a primary IP',
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 16 - 0
netbox/virtualization/tests/test_filters.py

@@ -1,6 +1,7 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
+from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.filters import *
 from virtualization.filters import *
@@ -266,6 +267,15 @@ class VirtualMachineTestCase(TestCase):
         )
         )
         VMInterface.objects.bulk_create(interfaces)
         VMInterface.objects.bulk_create(interfaces)
 
 
+        # Assign primary IPs for filtering
+        ipaddresses = (
+            IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
+            IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
+        )
+        IPAddress.objects.bulk_create(ipaddresses)
+        VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
+        VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
+
     def test_id(self):
     def test_id(self):
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -344,6 +354,12 @@ class VirtualMachineTestCase(TestCase):
         params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
         params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_has_primary_ip(self):
+        params = {'has_primary_ip': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'has_primary_ip': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_local_context_data(self):
     def test_local_context_data(self):
         params = {'local_context_data': 'true'}
         params = {'local_context_data': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

+ 9 - 0
scripts/git-hooks/pre-commit

@@ -13,6 +13,15 @@ EXIT=0
 RED='\033[0;31m'
 RED='\033[0;31m'
 NOCOLOR='\033[0m'
 NOCOLOR='\033[0m'
 
 
+if [ -d ./venv/ ]; then
+    VENV="$PWD/venv"
+    if [ -e $VENV/bin/python ]; then
+        PATH=$VENV/bin:$PATH
+    elif [ -e $VENV/Scripts/python.exe ]; then
+        PATH=$VENV/Scripts:$PATH
+    fi
+fi
+
 echo "Validating PEP8 compliance..."
 echo "Validating PEP8 compliance..."
 pycodestyle --ignore=W504,E501 netbox/
 pycodestyle --ignore=W504,E501 netbox/
 if [ $? != 0 ]; then
 if [ $? != 0 ]; then