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

Merge branch 'develop' into feature

jeremystretch 3 лет назад
Родитель
Сommit
c4dcd34ce9

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

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.3.5
+      placeholder: v3.3.6
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.3.5
+      placeholder: v3.3.6
     validations:
       required: true
   - type: dropdown

+ 5 - 4
.github/PULL_REQUEST_TEMPLATE.md

@@ -1,13 +1,14 @@
 <!--
     Thank you for your interest in contributing to NetBox! Please note that
     our contribution policy requires that a feature request or bug report be
-    approved and assigned prior to filing a pull request. This helps avoid
-    wasting time and effort on something that we might not be able to accept.
+    approved and assigned prior to opening a pull request. This helps avoid
+    waste time and effort on a proposed change that we might not be able to
+    accept.
 
     IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
-    TO YOU, IT WE BE CLOSED AUTOMATICALLY.
+    TO YOU, IT WILL BE CLOSED AUTOMATICALLY.
 
-    Specify your assigned issue number on the line below.
+    Please specify your assigned issue number on the line below.
 -->
 ### Fixes: #1234
 

+ 15 - 1
docs/release-notes/version-3.3.md

@@ -1,10 +1,17 @@
 # NetBox v3.3
 
-## v3.3.6 (FUTURE)
+## v3.3.7 (FUTURE)
+
+---
+
+## v3.3.6 (2022-10-26)
 
 ### Enhancements
 
+* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug
 * [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
+* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface
+* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH`
 * [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
 
 ### Bug Fixes
@@ -12,10 +19,17 @@
 * [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
 * [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
 * [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
+* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer
 * [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
 * [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
 * [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
+* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists
 * [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
+* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view
+* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation
+* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs
+* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list
+* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view
 
 ---
 

+ 7 - 1
netbox/dcim/filtersets.py

@@ -800,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         to_field_name='slug',
         label='Manufacturer (slug)',
     )
+    device_type = django_filters.ModelMultipleChoiceFilter(
+        field_name='device_type__slug',
+        queryset=DeviceType.objects.all(),
+        to_field_name='slug',
+        label='Device type (slug)',
+    )
     device_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceType.objects.all(),
         label='Device type (ID)',
@@ -1360,7 +1366,7 @@ class InterfaceFilterSet(
         try:
             devices = Device.objects.filter(pk__in=id_list)
             for device in devices:
-                vc_interface_ids += device.vc_interfaces().values_list('id', flat=True)
+                vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
             return queryset.filter(pk__in=vc_interface_ids)
         except Device.DoesNotExist:
             return queryset.none()

+ 2 - 0
netbox/dcim/tests/test_filtersets.py

@@ -1670,6 +1670,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_types = DeviceType.objects.all()[:2]
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device_type': [device_types[0].slug, device_types[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_devicerole(self):
         device_roles = DeviceRole.objects.all()[:2]

+ 2 - 1
netbox/ipam/forms/model_forms.py

@@ -550,6 +550,7 @@ class FHRPGroupForm(NetBoxModelForm):
 
     def save(self, *args, **kwargs):
         instance = super().save(*args, **kwargs)
+        user = getattr(instance, '_user', None)  # Set under FHRPGroupEditView.alter_object()
 
         # Check if we need to create a new IPAddress for the group
         if self.cleaned_data.get('ip_address'):
@@ -563,7 +564,7 @@ class FHRPGroupForm(NetBoxModelForm):
             ipaddress.save()
 
             # Check that the new IPAddress conforms with any assigned object-level permissions
-            if not IPAddress.objects.filter(pk=ipaddress.pk).first():
+            if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first():
                 raise PermissionsViolation()
 
         return instance

+ 1 - 1
netbox/ipam/tables/ip.py

@@ -375,7 +375,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
     )
     assigned = columns.BooleanColumn(
         accessor='assigned_object_id',
-        linkify=True,
+        linkify=lambda record: record.assigned_object.get_absolute_url(),
         verbose_name='Assigned'
     )
     tags = columns.TagColumn(

+ 6 - 0
netbox/ipam/views.py

@@ -985,6 +985,12 @@ class FHRPGroupEditView(generic.ObjectEditView):
 
         return return_url
 
+    def alter_object(self, obj, request, url_args, url_kwargs):
+        # Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that
+        # we can evaluate permissions during the creation of a new IPAddress within the form.
+        obj._user = request.user
+        return obj
+
 
 @register_model_view(FHRPGroup, 'delete')
 class FHRPGroupDeleteView(generic.ObjectDeleteView):

+ 10 - 8
netbox/netbox/api/authentication.py

@@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication):
         if token.is_expired:
             raise exceptions.AuthenticationFailed("Token expired")
 
-        if not token.user.is_active:
-            raise exceptions.AuthenticationFailed("User inactive")
-
+        user = token.user
         # When LDAP authentication is active try to load user data from LDAP directory
         if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
             from netbox.authentication import LDAPBackend
             ldap_backend = LDAPBackend()
 
             # Load from LDAP if FIND_GROUP_PERMS is active
-            if ldap_backend.settings.FIND_GROUP_PERMS:
-                user = ldap_backend.populate_user(token.user.username)
+            # Always query LDAP when user is not active, otherwise it is never activated again
+            if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
+                ldap_user = ldap_backend.populate_user(token.user.username)
                 # If the user is found in the LDAP directory use it, if not fallback to the local user
-                if user:
-                    return user, token
+                if ldap_user:
+                    user = ldap_user
+
+        if not user.is_active:
+            raise exceptions.AuthenticationFailed("User inactive")
 
-        return token.user, token
+        return user, token
 
 
 class TokenPermissions(DjangoObjectPermissions):

+ 2 - 3
netbox/netbox/api/viewsets/mixins.py

@@ -108,6 +108,5 @@ class ObjectValidationMixin:
             conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
             if conforming_count != len(instance):
                 raise ObjectDoesNotExist
-        else:
-            # Check that the instance is matched by the view's queryset
-            self.queryset.get(pk=instance.pk)
+        elif not self.queryset.filter(pk=instance.pk).exists():
+            raise ObjectDoesNotExist

+ 3 - 0
netbox/netbox/settings.py

@@ -79,6 +79,7 @@ CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
+CSRF_COOKIE_PATH = BASE_PATH or '/'
 CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
@@ -124,6 +125,8 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE',
 SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
+SESSION_COOKIE_PATH = BASE_PATH or '/'
+LANGUAGE_COOKIE_PATH = BASE_PATH or '/'
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')

+ 52 - 61
netbox/netbox/views/__init__.py

@@ -1,5 +1,6 @@
 import platform
 import sys
+from collections import namedtuple
 
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
@@ -8,6 +9,7 @@ from django.http import HttpResponseServerError
 from django.shortcuts import redirect, render
 from django.template import loader
 from django.template.exceptions import TemplateDoesNotExist
+from django.utils.translation import gettext as _
 from django.views.decorators.csrf import requires_csrf_token
 from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
 from django.views.generic import View
@@ -26,102 +28,91 @@ from netbox.forms import SearchForm
 from netbox.search import LookupTypes
 from netbox.search.backends import search_backend
 from netbox.tables import SearchTable
-from tenancy.models import Tenant
+from tenancy.models import Contact, Tenant
 from utilities.htmx import is_htmx
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from virtualization.models import Cluster, VirtualMachine
 from wireless.models import WirelessLAN, WirelessLink
 
+Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
+
 
 class HomeView(View):
     template_name = 'home.html'
 
     def get(self, request):
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
-            return redirect("login")
+            return redirect('login')
 
-        connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
+        console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
             _path__is_complete=True
-        )
-        connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
+        ).count
+        power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
             _path__is_complete=True
-        )
-        connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
+        ).count
+        interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
             _path__is_complete=True
-        )
+        ).count
+
+        def get_count_queryset(model):
+            return model.objects.restrict(request.user, 'view').count
 
         def build_stats():
             org = (
-                ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
-                ("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count),
+                Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)),
+                Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)),
+                Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)),
             )
             dcim = (
-                ("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count),
-                ("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count),
-                ("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count),
+                Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)),
+                Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)),
+                Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)),
             )
             ipam = (
-                ("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count),
-                ("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count),
-                ("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count),
-                ("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count),
-                ("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count),
-                ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count)
-
+                Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)),
+                Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)),
+                Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)),
+                Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)),
+                Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)),
+                Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)),
             )
             circuits = (
-                ("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count),
-                ("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count),
+                Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)),
+                Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit))
             )
             virtualization = (
-                ("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count),
-                ("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count),
-
+                Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster',
+                     get_count_queryset(Cluster)),
+                Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine',
+                     get_count_queryset(VirtualMachine)),
             )
             connections = (
-                ("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count),
-                ("dcim.view_consoleport", "Console", connected_consoleports.count),
-                ("dcim.view_interface", "Interfaces", connected_interfaces.count),
-                ("dcim.view_powerport", "Power Connections", connected_powerports.count),
+                Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)),
+                Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections),
+                Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections),
+                Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections),
             )
             power = (
-                ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count),
-                ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count),
+                Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)),
+                Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)),
             )
             wireless = (
-                ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count),
-                ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count),
+                Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan',
+                     get_count_queryset(WirelessLAN)),
+                Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink',
+                     get_count_queryset(WirelessLink)),
             )
-            sections = (
-                ("Organization", org, "domain"),
-                ("IPAM", ipam, "counter"),
-                ("Virtualization", virtualization, "monitor"),
-                ("Inventory", dcim, "server"),
-                ("Circuits", circuits, "transit-connection-variant"),
-                ("Connections", connections, "cable-data"),
-                ("Power", power, "flash"),
-                ("Wireless", wireless, "wifi"),
+            stats = (
+                (_('Organization'), org, 'domain'),
+                (_('IPAM'), ipam, 'counter'),
+                (_('Virtualization'), virtualization, 'monitor'),
+                (_('Inventory'), dcim, 'server'),
+                (_('Circuits'), circuits, 'transit-connection-variant'),
+                (_('Connections'), connections, 'cable-data'),
+                (_('Power'), power, 'flash'),
+                (_('Wireless'), wireless, 'wifi'),
             )
 
-            stats = []
-            for section_label, section_items, icon_class in sections:
-                items = []
-                for perm, item_label, get_count in section_items:
-                    app, scope = perm.split(".")
-                    url = ":".join((app, scope.replace("view_", "") + "_list"))
-                    item = {
-                        "label": item_label,
-                        "count": None,
-                        "url": url,
-                        "disabled": True,
-                        "icon": icon_class,
-                    }
-                    if request.user.has_perm(perm):
-                        item["count"] = get_count()
-                        item["disabled"] = False
-                    items.append(item)
-                stats.append((section_label, items, icon_class))
-
             return stats
 
         # Compile changelog table

+ 2 - 2
netbox/netbox/views/generic/object_views.py

@@ -179,7 +179,7 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
         obj = model_form.save()
 
         # Enforce object-level permissions
-        if not self.queryset.filter(pk=obj.pk).first():
+        if not self.queryset.filter(pk=obj.pk).exists():
             raise PermissionsViolation()
 
         # Iterate through the related object forms (if any), validating and saving each instance.
@@ -396,7 +396,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
                     obj = form.save()
 
                     # Check that the new object conforms with any assigned object-level permissions
-                    if not self.queryset.filter(pk=obj.pk).first():
+                    if not self.queryset.filter(pk=obj.pk).exists():
                         raise PermissionsViolation()
 
                 msg = '{} {}'.format(

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

@@ -178,7 +178,7 @@
                                 {% if object.primary_ip4.nat_inside %}
                                   (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                                 {% elif object.primary_ip4.nat_outside.exists %}
-                                  (NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                                  (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                 {% endif %}
                               {% else %}
                                 {{ ''|placeholder }}
@@ -193,7 +193,7 @@
                                 {% if object.primary_ip6.nat_inside %}
                                   (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                                 {% elif object.primary_ip6.nat_outside.exists %}
-                                  (NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                                  (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                                 {% endif %}
                               {% else %}
                                 {{ ''|placeholder }}

+ 2 - 0
netbox/templates/extras/tag.html

@@ -39,6 +39,7 @@
           </table>
         </div>
       </div>
+      {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
       <div class="card">
@@ -64,6 +65,7 @@
           </table>
         </div>
       </div>
+      {% plugin_right_page object %}
     </div>
   </div>
   <div class="row">

+ 2 - 2
netbox/templates/home.html

@@ -36,8 +36,8 @@
             <div class="card-body">
               <div class="list-group list-group-flush">
                 {% for item in items %}
-                  {% if not item.disabled %}
-                    <a href="{% url item.url %}" class="list-group-item list-group-item-action">
+                  {% if item.permission in perms %}
+                    <a href="{% url item.viewname %}" class="list-group-item list-group-item-action">
                       <div class="d-flex w-100 justify-content-between align-items-center">
                         {{ item.label }}
                         <h4 class="mb-1">{{ item.count }}</h4>

+ 4 - 0
netbox/templates/virtualization/cluster.html

@@ -19,6 +19,10 @@
                     <th scope="row">Type</th>
                     <td>{{ object.type|linkify }}</td>
                 </tr>
+                <tr>
+                  <th scope="row">Status</th>
+                  <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+                </tr>
                 <tr>
                     <th scope="row">Group</th>
                     <td>{{ object.group|linkify|placeholder }}</td>

+ 2 - 2
netbox/templates/virtualization/virtualmachine.html

@@ -46,7 +46,7 @@
                             {% if object.primary_ip4.nat_inside %}
                               (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
                             {% elif object.primary_ip4.nat_outside.exists %}
-                              (NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                              (NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                             {% endif %}
                           {% else %}
                             {{ ''|placeholder }}
@@ -61,7 +61,7 @@
                             {% if object.primary_ip6.nat_inside %}
                               (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
                             {% elif object.primary_ip6.nat_outside.exists %}
-                              (NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                              (NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
                             {% endif %}
                           {% else %}
                             {{ ''|placeholder }}

+ 1 - 0
netbox/virtualization/tables/clusters.py

@@ -64,6 +64,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     group = tables.Column(
         linkify=True
     )
+    status = columns.ChoiceFieldColumn()
     site = tables.Column(
         linkify=True
     )

+ 4 - 4
requirements.txt

@@ -19,18 +19,18 @@ graphene-django==3.0.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==8.5.6
+mkdocs-material==8.5.7
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 Pillow==9.2.0
-psycopg2-binary==2.9.3
+psycopg2-binary==2.9.5
 PyYAML==6.0
-sentry-sdk==1.9.10
+sentry-sdk==1.10.1
 social-auth-app-django==5.0.0
 social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3
 tablib==3.2.1
-tzdata==2022.4
+tzdata==2022.5
 
 # Workaround for #7401
 jsonschema==3.2.0