Просмотр исходного кода

Merge branch 'develop' into develop-2.10

Jeremy Stretch 5 лет назад
Родитель
Сommit
96650b0216
36 измененных файлов с 255 добавлено и 85 удалено
  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
     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
     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
     about: Please read through our contributing policy before opening an issue or pull request
   - 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

+ 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
     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
     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.
 -->
 ### 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

+ 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
 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
 
@@ -164,7 +164,7 @@ overlooked.
 * Official channels for communication include:
 
     * 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/)
 
 * 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/).
 
-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/)!
 
 ### Build Status
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
 
 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).)
-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
 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.
 
+## 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
 
 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
 * `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
 
 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
 
-### 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
 

+ 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:
 
 * [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.
 
 ## 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'
 ```
-
-### 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.
 
+!!! 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
 
 ## Plugin Structure

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

@@ -1,5 +1,39 @@
 # 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)
 
 **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.db import transaction
 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 utilities.forms import ConfirmationForm
-from utilities.paginator import EnhancedPaginator
+from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
@@ -43,7 +42,7 @@ class ProviderView(ObjectView):
 
         paginate = {
             '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)
 

+ 3 - 9
netbox/dcim/filters.py

@@ -665,16 +665,10 @@ class DeviceFilterSet(
         ).distinct()
 
     def _has_primary_ip(self, queryset, name, value):
+        params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
         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):
         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['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
         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'].widget.attrs['disabled'] = True
 

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

@@ -316,6 +316,14 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
             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):
         """
         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
         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
         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')
@@ -704,6 +711,16 @@ class RearPort(CableTermination, ComponentModel):
     def get_absolute_url(self):
         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):
         return (
             self.device.identifier,

+ 2 - 3
netbox/dcim/views.py

@@ -1,6 +1,5 @@
 from collections import OrderedDict
 
-from django.conf import settings
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 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 secrets.models import Secret
 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.utils import csv_format, get_subquery
 from utilities.views import (
@@ -317,7 +316,7 @@ class RackElevationListView(ObjectListView):
             racks = racks.reverse()
 
         # Pagination
-        per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
+        per_page = get_paginate_count(request)
         page_number = request.GET.get('page', 1)
         paginator = EnhancedPaginator(racks, per_page)
         try:

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

@@ -175,13 +175,14 @@ class CustomField(models.Model):
         # Select
         elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
             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)
 
             # 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 = field_class(

+ 1 - 3
netbox/extras/tables.py

@@ -22,10 +22,8 @@ CONFIGCONTEXT_ACTIONS = """
 """
 
 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>
-{% elif record.action != 3 and record.related_object.get_absolute_url %}
-    <a href="{{ record.related_object.get_absolute_url }}">{{ record.object_repr }}</a>
 {% else %}
     {{ record.object_repr }}
 {% 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'
 
 
-@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.
     """
@@ -30,8 +30,13 @@ def custom_links(obj):
     if not custom_links:
         return ''
 
-    context = {
+    # Pass select context data when rendering the CustomLink
+    link_context = {
         '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 = ''
     group_names = OrderedDict()
@@ -47,9 +52,9 @@ def custom_links(obj):
         # Add non-grouped links
         else:
             try:
-                text_rendered = render_jinja2(cl.text, context)
+                text_rendered = render_jinja2(cl.text, link_context)
                 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 ''
                     template_code += LINK_BUTTON.format(
                         link_rendered, link_target, cl.button_class, text_rendered
@@ -65,10 +70,10 @@ def custom_links(obj):
 
         for cl in links:
             try:
-                text_rendered = render_jinja2(cl.text, context)
+                text_rendered = render_jinja2(cl.text, link_context)
                 if text_rendered:
                     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(
                         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.conf import settings
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 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 tenancy.models import Tenant, TenantGroup
 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.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -258,7 +257,7 @@ class ObjectChangeLogView(View):
         # Apply the request context
         paginate = {
             '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)
 

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

@@ -233,7 +233,8 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
     assigned_object_type = ContentTypeField(
         queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
-        required=False
+        required=False,
+        allow_null=True
     )
     assigned_object = serializers.SerializerMethodField(read_only=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 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.views import (
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
@@ -305,7 +305,7 @@ class AggregateView(ObjectView):
 
         paginate = {
             '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)
 
@@ -463,7 +463,7 @@ class PrefixPrefixesView(ObjectView):
 
         paginate = {
             '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)
 
@@ -507,7 +507,7 @@ class PrefixIPAddressesView(ObjectView):
 
         paginate = {
             '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)
 
@@ -599,7 +599,8 @@ class IPAddressView(ObjectView):
         # Exclude anycast IPs if this IP is anycast
         if ipaddress.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_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
@@ -611,7 +612,7 @@ class IPAddressView(ObjectView):
 
         paginate = {
             '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)
 
@@ -619,6 +620,7 @@ class IPAddressView(ObjectView):
             'ipaddress': ipaddress,
             'parent_prefixes_table': parent_prefixes_table,
             'duplicate_ips_table': duplicate_ips_table,
+            'more_duplicate_ips': duplicate_ips.count() > 10,
             'related_ips_table': related_ips_table,
         })
 
@@ -771,7 +773,7 @@ class VLANGroupVLANsView(ObjectView):
 
         paginate = {
             '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)
 
@@ -785,6 +787,7 @@ class VLANGroupVLANsView(ObjectView):
         return render(request, 'ipam/vlangroup_vlans.html', {
             'vlan_group': vlan_group,
             'first_available_vlan': vlan_group.get_next_available_vid(),
+            'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
             'vlan_table': vlan_table,
             'permissions': permissions,
         })
@@ -831,7 +834,7 @@ class VLANInterfacesView(ObjectView):
 
         paginate = {
             '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)
 
@@ -852,7 +855,7 @@ class VLANVMInterfacesView(ObjectView):
 
         paginate = {
             '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)
 

+ 1 - 1
netbox/netbox/authentication.py

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

+ 1 - 1
netbox/templates/500.html

@@ -36,7 +36,7 @@
 Python version: {{ python_version }}
 NetBox version: {{ netbox_version }}</pre>
                         <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>
                         <div class="text-right">
                             <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>
     </div>
     <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>
     </div>
 </div>
@@ -23,10 +23,23 @@
             <div style="white-space: nowrap; overflow-x: scroll;">
                 {% for rack in page %}
                     <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 %}
                         <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>
                 {% endfor %}
             </div>

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

@@ -3,6 +3,7 @@
 {% load custom_links %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block header %}
     <div class="row noprint">
@@ -159,7 +160,24 @@
 	<div class="col-md-8">
         {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
         {% 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 %}
         {% 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 %}

+ 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"
                 "NOT resolve any issues with missing or unapplied migrations. For assistance,\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)

+ 2 - 1
netbox/utilities/tables.py

@@ -1,4 +1,5 @@
 import django_tables2 as tables
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
@@ -64,7 +65,7 @@ class BaseTable(tables.Table):
                     field_path = column.accessor.split('.')
                     try:
                         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))
                     except FieldDoesNotExist:
                         pass

+ 1 - 1
netbox/utilities/views.py

@@ -1323,7 +1323,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
                         for obj in data['pk']:
 
                             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):
                                 label = labels[i] if labels else None
 

+ 10 - 0
netbox/virtualization/filters.py

@@ -186,6 +186,10 @@ class VirtualMachineFilterSet(
         field_name='interfaces__mac_address',
         label='MAC address',
     )
+    has_primary_ip = django_filters.BooleanFilter(
+        method='_has_primary_ip',
+        label='Has a primary IP',
+    )
     tag = TagFilter()
 
     class Meta:
@@ -200,6 +204,12 @@ class VirtualMachineFilterSet(
             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):
     q = django_filters.CharFilter(

+ 7 - 0
netbox/virtualization/forms.py

@@ -516,6 +516,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         required=False,
         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)
 
 

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

@@ -1,6 +1,7 @@
 from django.test import TestCase
 
 from dcim.models import DeviceRole, Platform, Region, Site
+from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from virtualization.choices import *
 from virtualization.filters import *
@@ -266,6 +267,15 @@ class VirtualMachineTestCase(TestCase):
         )
         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):
         params = {'id': self.queryset.values_list('pk', flat=True)[: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']}
         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):
         params = {'local_context_data': 'true'}
         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'
 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..."
 pycodestyle --ignore=W504,E501 netbox/
 if [ $? != 0 ]; then