jeremystretch 4 лет назад
Родитель
Сommit
0978777eec

+ 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.1.3
+      placeholder: v3.1.4
     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.1.3
+      placeholder: v3.1.4
     validations:
       required: true
   - type: dropdown

+ 1 - 0
.gitignore

@@ -8,6 +8,7 @@ yarn-error.log*
 !/netbox/project-static/docs/.info
 /netbox/netbox/configuration.py
 /netbox/netbox/ldap_config.py
+/netbox/local/*
 /netbox/reports/*
 !/netbox/reports/__init__.py
 /netbox/scripts/*

+ 36 - 1
README.md

@@ -5,11 +5,46 @@
 ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
 
 NetBox is an infrastructure resource modeling (IRM) tool designed to empower
-network automation. Initially conceived by the network engineering team at
+network automation, used by thousands of organizations around the world.
+Initially conceived by the network engineering team at
 [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
 to address the needs of network and infrastructure engineers. It is intended to
 function as a domain-specific source of truth for network operations.
 
+Myriad infrastructure components can be modeled in NetBox, including:
+
+* Hierarchical regions, site groups, sites, and locations
+* Racks, devices, and device components
+* Cables and wireless connections
+* Power distribution
+* Data circuits and providers
+* Virtual machines and clusters
+* IP prefixes, ranges, and addresses
+* VRFs and route targets
+* FHRP groups (VRRP, HSRP, etc.)
+* AS numbers
+* VLANs and scoped VLAN groups
+* Organizational tenants and contacts
+
+In addition to its extensive built-in models and functionality, NetBox can be
+customized and extended through the use of:
+
+* Custom fields
+* Custom links
+* Configuration contexts
+* Custom model validation rules
+* Reports
+* Custom scripts
+* Export templates
+* Conditional webhooks
+* Plugins
+* Single sign-on (SSO) authentication
+* NAPALM integration
+* Detailed change logging
+
+NetBox also features a complete REST API as well as a GraphQL API for easily
+integrating with other tools and systems.
+
 NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
 Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
 complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).

+ 17 - 8
docs/development/adding-models.md

@@ -37,23 +37,32 @@ Most models will need view classes created in `views.py` to serve the following
 
 Add the relevant URL path for each view created in the previous step to `urls.py`.
 
-## 6. Create the FilterSet
+## 6. Add relevant forms
+
+Depending on the type of model being added, you may need to define several types of form classes. These include:
+
+* A base model form (for creating/editing individual objects)
+* A bulk edit form
+* A bulk import form (for CSV-based import)
+* A filterset form (for filtering the object list view)
+
+## 7. Create the FilterSet
 
 Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
 
-## 7. Create the table class
+## 8. Create the table class
 
 Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
 
-## 8. Create the object template
+## 9. Create the object template
 
 Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
 
-## 9. Add the model to the navigation menu
+## 10. Add the model to the navigation menu
 
 Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
 
-## 10. REST API components
+## 11. REST API components
 
 Create the following for each model:
 
@@ -62,13 +71,13 @@ Create the following for each model:
 * API view in `api/views.py`
 * Endpoint route in `api/urls.py`
 
-## 11. GraphQL API components
+## 12. GraphQL API components
 
 Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
 
 Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
 
-## 12. Add tests
+## 13. Add tests
 
 Add tests for the following:
 
@@ -76,7 +85,7 @@ Add tests for the following:
 * API views
 * Filter sets
 
-## 13. Documentation
+## 14. Documentation
 
 Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.
 

+ 1 - 1
docs/installation/6-ldap.md

@@ -152,7 +152,7 @@ LOGGING = {
         'netbox_auth_log': {
             'level': 'DEBUG',
             'class': 'logging.handlers.RotatingFileHandler',
-            'filename': '/opt/netbox/logs/django-ldap-debug.log',
+            'filename': '/opt/netbox/local/logs/django-ldap-debug.log',
             'maxBytes': 1024 * 500,
             'backupCount': 5,
         },

+ 19 - 1
docs/release-notes/version-3.1.md

@@ -1,6 +1,24 @@
 # NetBox v3.1
 
-## v3.1.4 (FUTURE)
+## v3.1.5 (FUTURE)
+
+---
+
+## v3.1.4 (2022-01-03)
+
+### Enhancements
+
+* [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view
+* [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI
+* [#8197](https://github.com/netbox-community/netbox/issues/8197) - Allow filtering sites by group when connecting a cable
+* [#8210](https://github.com/netbox-community/netbox/issues/8210) - Establish `netbox/local/` as a path for local resources
+
+### Bug Fixes
+
+* [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables
+* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces
+* [#8196](https://github.com/netbox-community/netbox/issues/8196) - Fix IndexError exception when viewing large IPv6 prefixes in UI
+* [#8201](https://github.com/netbox-community/netbox/issues/8201) - Custom integer fields should allow negative integers as minimum/maximum values
 
 ---
 

+ 1 - 1
docs/rest-api/authentication.md

@@ -42,7 +42,7 @@ $ curl -X POST \
 https://netbox/api/users/tokens/provision/ \
 --data '{
     "username": "hankhill",
-    "password: "I<3C3H8",
+    "password": "I<3C3H8",
 }'
 ```
 

+ 15 - 14
netbox/dcim/forms/connections.py

@@ -27,7 +27,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
         label='Region',
         required=False
     )
-    termination_b_site_group = DynamicModelChoiceField(
+    termination_b_sitegroup = DynamicModelChoiceField(
         queryset=SiteGroup.objects.all(),
         label='Site group',
         required=False
@@ -38,7 +38,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
         required=False,
         query_params={
             'region_id': '$termination_b_region',
-            'group_id': '$termination_b_site_group',
+            'group_id': '$termination_b_sitegroup',
         }
     )
     termination_b_location = DynamicModelChoiceField(
@@ -78,9 +78,9 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
     class Meta:
         model = Cable
         fields = [
-            'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
-            'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
-            'tags',
+            'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
+            'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
+            'length', 'length_unit', 'tags',
         ]
         widgets = {
             'status': StaticSelect,
@@ -182,7 +182,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
         label='Region',
         required=False
     )
-    termination_b_site_group = DynamicModelChoiceField(
+    termination_b_sitegroup = DynamicModelChoiceField(
         queryset=SiteGroup.objects.all(),
         label='Site group',
         required=False
@@ -193,7 +193,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
         required=False,
         query_params={
             'region_id': '$termination_b_region',
-            'group_id': '$termination_b_site_group',
+            'group_id': '$termination_b_sitegroup',
         }
     )
     termination_b_circuit = DynamicModelChoiceField(
@@ -219,9 +219,9 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
 
     class Meta(ConnectCableToDeviceForm.Meta):
         fields = [
-            'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
-            'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
-            'tags',
+            'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
+            'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
+            'length', 'length_unit', 'tags',
         ]
 
     def clean_termination_b_id(self):
@@ -235,7 +235,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
         label='Region',
         required=False
     )
-    termination_b_site_group = DynamicModelChoiceField(
+    termination_b_sitegroup = DynamicModelChoiceField(
         queryset=SiteGroup.objects.all(),
         label='Site group',
         required=False
@@ -246,7 +246,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
         required=False,
         query_params={
             'region_id': '$termination_b_region',
-            'group_id': '$termination_b_site_group',
+            'group_id': '$termination_b_sitegroup',
         }
     )
     termination_b_location = DynamicModelChoiceField(
@@ -281,8 +281,9 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
 
     class Meta(ConnectCableToDeviceForm.Meta):
         fields = [
-            'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
-            'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
+            'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
+            'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
+            'color', 'length', 'length_unit', 'tags',
         ]
 
     def clean_termination_b_id(self):

+ 1 - 1
netbox/dcim/migrations/0146_inventoryitemrole.py

@@ -8,7 +8,7 @@ import utilities.fields
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('extras', '0067_configcontext_cluster_types'),
+        ('extras', '0068_configcontext_cluster_types'),
         ('dcim', '0145_modules'),
     ]
 

+ 1 - 1
netbox/dcim/tables/racks.py

@@ -92,7 +92,7 @@ class RackTable(BaseTable):
         )
         default_columns = (
             'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
-            'get_utilization', 'get_power_utilization',
+            'get_utilization',
         )
 
 

+ 21 - 0
netbox/extras/migrations/0067_customfield_min_max_values.py

@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0066_customfield_name_validation'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='customfield',
+            name='validation_maximum',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='validation_minimum',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+    ]

+ 1 - 1
netbox/extras/migrations/0067_configcontext_cluster_types.py → netbox/extras/migrations/0068_configcontext_cluster_types.py

@@ -5,7 +5,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('virtualization', '0026_vminterface_bridge'),
-        ('extras', '0066_customfield_name_validation'),
+        ('extras', '0067_customfield_min_max_values'),
     ]
 
     operations = [

+ 2 - 2
netbox/extras/models/customfields.py

@@ -96,13 +96,13 @@ class CustomField(ChangeLoggedModel):
         default=100,
         help_text='Fields with higher weights appear lower in a form.'
     )
-    validation_minimum = models.PositiveIntegerField(
+    validation_minimum = models.IntegerField(
         blank=True,
         null=True,
         verbose_name='Minimum value',
         help_text='Minimum allowed value (for numeric fields)'
     )
-    validation_maximum = models.PositiveIntegerField(
+    validation_maximum = models.IntegerField(
         blank=True,
         null=True,
         verbose_name='Maximum value',

+ 49 - 30
netbox/extras/tests/test_customfields.py

@@ -25,49 +25,68 @@ class CustomFieldTest(TestCase):
     def test_simple_fields(self):
         DATA = (
             {
-                'field_type': CustomFieldTypeChoices.TYPE_TEXT,
-                'field_value': 'Foobar!',
-                'empty_value': '',
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_TEXT,
+                },
+                'value': 'Foobar!',
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT,
-                'field_value': 'Text with **Markdown**',
-                'empty_value': '',
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
+                },
+                'value': 'Text with **Markdown**',
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
-                'field_value': 0,
-                'empty_value': None,
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_INTEGER,
+                },
+                'value': 0,
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
-                'field_value': 42,
-                'empty_value': None,
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_INTEGER,
+                    'validation_minimum': 1,
+                    'validation_maximum': 100,
+                },
+                'value': 42,
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
-                'field_value': True,
-                'empty_value': None,
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_INTEGER,
+                    'validation_minimum': -100,
+                    'validation_maximum': -1,
+                },
+                'value': -42,
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
-                'field_value': False,
-                'empty_value': None,
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
+                },
+                'value': True,
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_DATE,
-                'field_value': '2016-06-23',
-                'empty_value': None,
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
+                },
+                'value': False,
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_URL,
-                'field_value': 'http://example.com/',
-                'empty_value': '',
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_DATE,
+                },
+                'value': '2016-06-23',
             },
             {
-                'field_type': CustomFieldTypeChoices.TYPE_JSON,
-                'field_value': '{"foo": 1, "bar": 2}',
-                'empty_value': 'null',
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_URL,
+                },
+                'value': 'http://example.com/',
+            },
+            {
+                'field': {
+                    'type': CustomFieldTypeChoices.TYPE_JSON,
+                },
+                'value': '{"foo": 1, "bar": 2}',
             },
         )
 
@@ -76,7 +95,7 @@ class CustomFieldTest(TestCase):
         for data in DATA:
 
             # Create a custom field
-            cf = CustomField(type=data['field_type'], name='my_field', required=False)
+            cf = CustomField(name='my_field', required=False, **data['field'])
             cf.save()
             cf.content_types.set([obj_type])
 
@@ -85,12 +104,12 @@ class CustomFieldTest(TestCase):
             self.assertIsNone(site.custom_field_data[cf.name])
 
             # Assign a value to the first Site
-            site.custom_field_data[cf.name] = data['field_value']
+            site.custom_field_data[cf.name] = data['value']
             site.save()
 
             # Retrieve the stored value
             site.refresh_from_db()
-            self.assertEqual(site.custom_field_data[cf.name], data['field_value'])
+            self.assertEqual(site.custom_field_data[cf.name], data['value'])
 
             # Delete the stored value
             site.custom_field_data.pop(cf.name)

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

@@ -302,7 +302,8 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     )
     dns_name = forms.CharField(
         max_length=255,
-        required=False
+        required=False,
+        label='DNS name'
     )
     description = forms.CharField(
         max_length=100,

+ 24 - 21
netbox/ipam/models/ip.py

@@ -32,6 +32,28 @@ __all__ = (
 )
 
 
+class GetAvailablePrefixesMixin:
+
+    def get_available_prefixes(self):
+        """
+        Return all available Prefixes within this aggregate as an IPSet.
+        """
+        prefix = netaddr.IPSet(self.prefix)
+        child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
+        available_prefixes = prefix - child_prefixes
+
+        return available_prefixes
+
+    def get_first_available_prefix(self):
+        """
+        Return the first available child prefix within the prefix (or None).
+        """
+        available_prefixes = self.get_available_prefixes()
+        if not available_prefixes:
+            return None
+        return available_prefixes.iter_cidrs()[0]
+
+
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RIR(OrganizationalModel):
     """
@@ -110,7 +132,7 @@ class ASN(PrimaryModel):
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Aggregate(PrimaryModel):
+class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
     """
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
     the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@@ -245,7 +267,7 @@ class Role(OrganizationalModel):
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Prefix(PrimaryModel):
+class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
     """
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
     VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@@ -458,16 +480,6 @@ class Prefix(PrimaryModel):
         else:
             return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
 
-    def get_available_prefixes(self):
-        """
-        Return all available Prefixes within this prefix as an IPSet.
-        """
-        prefix = netaddr.IPSet(self.prefix)
-        child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
-        available_prefixes = prefix - child_prefixes
-
-        return available_prefixes
-
     def get_available_ips(self):
         """
         Return all available IPs within this prefix as an IPSet.
@@ -494,15 +506,6 @@ class Prefix(PrimaryModel):
 
         return available_ips
 
-    def get_first_available_prefix(self):
-        """
-        Return the first available child prefix within the prefix (or None).
-        """
-        available_prefixes = self.get_available_prefixes()
-        if not available_prefixes:
-            return None
-        return available_prefixes.iter_cidrs()[0]
-
     def get_first_available_ip(self):
         """
         Return the first available IP within the prefix (or None).

+ 13 - 4
netbox/ipam/views.py

@@ -299,6 +299,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
         return {
             'bulk_querystring': f'within={instance.prefix}',
             'active_tab': 'prefixes',
+            'first_available_prefix': instance.get_first_available_prefix(),
             'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
             'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
         }
@@ -455,7 +456,9 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
     template_name = 'ipam/prefix/prefixes.html'
 
     def get_children(self, request, parent):
-        return parent.get_child_prefixes().restrict(request.user, 'view')
+        return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
+            'site', 'vrf', 'vlan', 'role', 'tenant',
+        )
 
     def prep_table_data(self, request, queryset, parent):
         # Determine whether to show assigned prefixes, available prefixes, or both
@@ -482,7 +485,9 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
     template_name = 'ipam/prefix/ip_ranges.html'
 
     def get_children(self, request, parent):
-        return parent.get_child_ranges().restrict(request.user, 'view')
+        return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
+            'vrf', 'role', 'tenant',
+        )
 
     def get_extra_context(self, request, instance):
         return {
@@ -500,7 +505,9 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     template_name = 'ipam/prefix/ip_addresses.html'
 
     def get_children(self, request, parent):
-        return parent.get_child_ips().restrict(request.user, 'view')
+        return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
+            'vrf', 'role', 'tenant',
+        )
 
     def prep_table_data(self, request, queryset, parent):
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
@@ -569,7 +576,9 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
     template_name = 'ipam/iprange/ip_addresses.html'
 
     def get_children(self, request, parent):
-        return parent.get_child_ips().restrict(request.user, 'view')
+        return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
+            'vrf', 'role', 'tenant',
+        )
 
     def get_extra_context(self, request, instance):
         return {

+ 1 - 1
netbox/netbox/navigation_menu.py

@@ -180,7 +180,7 @@ CONNECTIONS_MENU = Menu(
             label='Connections',
             items=(
                 get_model_item('dcim', 'cable', 'Cables', actions=['import']),
-                get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
+                get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']),
                 MenuItem(
                     link='dcim:interface_connections_list',
                     link_text='Interface Connections',

+ 17 - 0
netbox/templates/dcim/cable_connect.html

@@ -5,6 +5,14 @@
 
 {% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}
 
+{% block tabs %}
+<ul class="nav nav-tabs px-3">
+  <li class="nav-item" role="presentation">
+    <a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
+  </li>
+</ul>
+{% endblock %}
+
 {% block content-wrapper %}
   <div class="tab-content">
     {% with termination_a=form.instance.termination_a %}
@@ -27,6 +35,12 @@
                                   <input class="form-control" value="{{ termination_a.device.site.region }}" disabled />
                               </div>
                           </div>
+                          <div class="row mb-3">
+                              <label class="col-sm-3 col-form-label text-lg-end">Site Group</label>
+                              <div class="col">
+                                  <input class="form-control" value="{{ termination_a.device.site.group }}" disabled />
+                              </div>
+                          </div>
                           <div class="row mb-3">
                               <label class="col-sm-3 col-form-label text-lg-end">Site</label>
                               <div class="col">
@@ -115,6 +129,9 @@
                       {% if 'termination_b_region' in form.fields %}
                           {% render_field form.termination_b_region %}
                       {% endif %}
+                      {% if 'termination_b_sitegroup' in form.fields %}
+                          {% render_field form.termination_b_sitegroup %}
+                      {% endif %}
                       {% if 'termination_b_site' in form.fields %}
                           {% render_field form.termination_b_site %}
                       {% endif %}

+ 5 - 0
netbox/templates/ipam/aggregate/prefixes.html

@@ -3,6 +3,11 @@
 
 {% block extra_controls %}
   {% include 'ipam/inc/toggle_available.html' %}
+  {% if perms.ipam.add_prefix and first_available_prefix %}
+    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}" class="btn btn-sm btn-primary">
+      <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Prefix
+    </a>
+  {% endif %}
   {{ block.super }}
 {% endblock %}
 

+ 11 - 1
netbox/templates/ipam/prefix.html

@@ -1,4 +1,5 @@
 {% extends 'ipam/prefix/base.html' %}
+{% load humanize %}
 {% load helpers %}
 {% load plugins %}
 
@@ -124,9 +125,18 @@
                 <a href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">{{ child_ip_count }}</a>
               </td>
             </tr>
+          {% endwith %}
+          {% with available_count=object.get_available_ips.size %}
             <tr>
               <th scope="row">Available IPs</th>
-              <td>{{ object.get_available_ips|length }}</td>
+              <td>
+                {# Use human-friendly words for counts greater than one million #}
+                {% if available_count > 1000000 %}
+                  {{ available_count|intword }}
+                {% else %}
+                  {{ available_count|intcomma }}
+                {% endif %}
+              </td>
             </tr>
           {% endwith %}
           <tr>

+ 1 - 1
netbox/templates/ipam/prefix/ip_ranges.html

@@ -3,7 +3,7 @@
 
 {% block extra_controls %}
   {% if perms.ipam.add_iprange and first_available_ip %}
-    <a href="{% url 'ipam:iprange_add' %}?start_address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-primary">
+    <a href="{% url 'ipam:iprange_add' %}?start_address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-sm btn-primary">
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Range
     </a>
   {% endif %}

+ 0 - 294
netbox/users/admin.py

@@ -1,294 +0,0 @@
-from django import forms
-from django.contrib import admin
-from django.contrib.auth.admin import UserAdmin as UserAdmin_
-from django.contrib.auth.models import Group, User
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldError, ValidationError
-
-from utilities.forms.fields import ContentTypeMultipleChoiceField
-from .constants import *
-from .models import ObjectPermission, Token, UserConfig
-
-
-#
-# Inline models
-#
-
-class ObjectPermissionInline(admin.TabularInline):
-    exclude = None
-    extra = 3
-    readonly_fields = ['object_types', 'actions', 'constraints']
-    verbose_name = 'Permission'
-    verbose_name_plural = 'Permissions'
-
-    def get_queryset(self, request):
-        return super().get_queryset(request).prefetch_related('objectpermission__object_types')
-
-    @staticmethod
-    def object_types(instance):
-        # Don't call .values_list() here because we want to reference the pre-fetched object_types
-        return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()])
-
-    @staticmethod
-    def actions(instance):
-        return ', '.join(instance.objectpermission.actions)
-
-    @staticmethod
-    def constraints(instance):
-        return instance.objectpermission.constraints
-
-
-class GroupObjectPermissionInline(ObjectPermissionInline):
-    model = Group.object_permissions.through
-
-
-class UserObjectPermissionInline(ObjectPermissionInline):
-    model = User.object_permissions.through
-
-
-class UserConfigInline(admin.TabularInline):
-    model = UserConfig
-    readonly_fields = ('data',)
-    can_delete = False
-    verbose_name = 'Preferences'
-
-
-#
-# Users & groups
-#
-
-# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
-admin.site.unregister(Group)
-admin.site.unregister(User)
-
-
-@admin.register(Group)
-class GroupAdmin(admin.ModelAdmin):
-    fields = ('name',)
-    list_display = ('name', 'user_count')
-    ordering = ('name',)
-    search_fields = ('name',)
-    inlines = [GroupObjectPermissionInline]
-
-    @staticmethod
-    def user_count(obj):
-        return obj.user_set.count()
-
-
-@admin.register(User)
-class UserAdmin(UserAdmin_):
-    list_display = [
-        'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
-    ]
-    fieldsets = (
-        (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
-        ('Groups', {'fields': ('groups',)}),
-        ('Status', {
-            'fields': ('is_active', 'is_staff', 'is_superuser'),
-        }),
-        ('Important dates', {'fields': ('last_login', 'date_joined')}),
-    )
-    filter_horizontal = ('groups',)
-    list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
-
-    def get_inlines(self, request, obj):
-        if obj is not None:
-            return (UserObjectPermissionInline, UserConfigInline)
-        return ()
-
-
-#
-# REST API tokens
-#
-
-class TokenAdminForm(forms.ModelForm):
-    key = forms.CharField(
-        required=False,
-        help_text="If no key is provided, one will be generated automatically."
-    )
-
-    class Meta:
-        fields = [
-            'user', 'key', 'write_enabled', 'expires', 'description'
-        ]
-        model = Token
-
-
-@admin.register(Token)
-class TokenAdmin(admin.ModelAdmin):
-    form = TokenAdminForm
-    list_display = [
-        'key', 'user', 'created', 'expires', 'write_enabled', 'description'
-    ]
-
-
-#
-# Permissions
-#
-
-class ObjectPermissionForm(forms.ModelForm):
-    object_types = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES
-    )
-    can_view = forms.BooleanField(required=False)
-    can_add = forms.BooleanField(required=False)
-    can_change = forms.BooleanField(required=False)
-    can_delete = forms.BooleanField(required=False)
-
-    class Meta:
-        model = ObjectPermission
-        exclude = []
-        help_texts = {
-            'actions': 'Actions granted in addition to those listed above',
-            'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
-                           'to match all objects of this type. A list of multiple objects will result in a logical OR '
-                           'operation.'
-        }
-        labels = {
-            'actions': 'Additional actions'
-        }
-        widgets = {
-            'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'})
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Make the actions field optional since the admin form uses it only for non-CRUD actions
-        self.fields['actions'].required = False
-
-        # Order group and user fields
-        self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
-        self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
-
-        # Check the appropriate checkboxes when editing an existing ObjectPermission
-        if self.instance.pk:
-            for action in ['view', 'add', 'change', 'delete']:
-                if action in self.instance.actions:
-                    self.fields[f'can_{action}'].initial = True
-                    self.instance.actions.remove(action)
-
-    def clean(self):
-        super().clean()
-
-        object_types = self.cleaned_data.get('object_types')
-        constraints = self.cleaned_data.get('constraints')
-
-        # Append any of the selected CRUD checkboxes to the actions list
-        if not self.cleaned_data.get('actions'):
-            self.cleaned_data['actions'] = list()
-        for action in ['view', 'add', 'change', 'delete']:
-            if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
-                self.cleaned_data['actions'].append(action)
-
-        # At least one action must be specified
-        if not self.cleaned_data['actions']:
-            raise ValidationError("At least one action must be selected.")
-
-        # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
-        # returns anything; we just want to make sure the specified constraints are valid.
-        if object_types and constraints:
-            # Normalize the constraints to a list of dicts
-            if type(constraints) is not list:
-                constraints = [constraints]
-            for ct in object_types:
-                model = ct.model_class()
-                try:
-                    model.objects.filter(*[Q(**c) for c in constraints]).exists()
-                except FieldError as e:
-                    raise ValidationError({
-                        'constraints': f'Invalid filter for {model}: {e}'
-                    })
-
-
-class ActionListFilter(admin.SimpleListFilter):
-    title = 'action'
-    parameter_name = 'action'
-
-    def lookups(self, request, model_admin):
-        options = set()
-        for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct():
-            options.update(action_list)
-        return [
-            (action, action) for action in sorted(options)
-        ]
-
-    def queryset(self, request, queryset):
-        if self.value():
-            return queryset.filter(actions=[self.value()])
-
-
-class ObjectTypeListFilter(admin.SimpleListFilter):
-    title = 'object type'
-    parameter_name = 'object_type'
-
-    def lookups(self, request, model_admin):
-        object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct()
-        content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model')
-        return [
-            (ct.pk, ct) for ct in content_types
-        ]
-
-    def queryset(self, request, queryset):
-        if self.value():
-            return queryset.filter(object_types=self.value())
-
-
-@admin.register(ObjectPermission)
-class ObjectPermissionAdmin(admin.ModelAdmin):
-    actions = ('enable', 'disable')
-    fieldsets = (
-        (None, {
-            'fields': ('name', 'description', 'enabled')
-        }),
-        ('Actions', {
-            'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
-        }),
-        ('Objects', {
-            'fields': ('object_types',)
-        }),
-        ('Assignment', {
-            'fields': ('groups', 'users')
-        }),
-        ('Constraints', {
-            'fields': ('constraints',),
-            'classes': ('monospace',)
-        }),
-    )
-    filter_horizontal = ('object_types', 'groups', 'users')
-    form = ObjectPermissionForm
-    list_display = [
-        'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
-    ]
-    list_filter = [
-        'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users'
-    ]
-    search_fields = ['actions', 'constraints', 'description', 'name']
-
-    def get_queryset(self, request):
-        return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
-
-    def list_models(self, obj):
-        return ', '.join([f"{ct}" for ct in obj.object_types.all()])
-    list_models.short_description = 'Models'
-
-    def list_users(self, obj):
-        return ', '.join([u.username for u in obj.users.all()])
-    list_users.short_description = 'Users'
-
-    def list_groups(self, obj):
-        return ', '.join([g.name for g in obj.groups.all()])
-    list_groups.short_description = 'Groups'
-
-    #
-    # Admin actions
-    #
-
-    def enable(self, request, queryset):
-        updated = queryset.update(enabled=True)
-        self.message_user(request, f"Enabled {updated} permissions")
-
-    def disable(self, request, queryset):
-        updated = queryset.update(enabled=False)
-        self.message_user(request, f"Disabled {updated} permissions")

+ 125 - 0
netbox/users/admin/__init__.py

@@ -0,0 +1,125 @@
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin as UserAdmin_
+from django.contrib.auth.models import Group, User
+
+from users.models import ObjectPermission, Token
+from . import filters, forms, inlines
+
+
+#
+# Users & groups
+#
+
+# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
+admin.site.unregister(Group)
+admin.site.unregister(User)
+
+
+@admin.register(Group)
+class GroupAdmin(admin.ModelAdmin):
+    form = forms.GroupAdminForm
+    list_display = ('name', 'user_count')
+    ordering = ('name',)
+    search_fields = ('name',)
+    inlines = [inlines.GroupObjectPermissionInline]
+
+    @staticmethod
+    def user_count(obj):
+        return obj.user_set.count()
+
+
+@admin.register(User)
+class UserAdmin(UserAdmin_):
+    list_display = [
+        'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
+    ]
+    fieldsets = (
+        (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
+        ('Groups', {'fields': ('groups',)}),
+        ('Status', {
+            'fields': ('is_active', 'is_staff', 'is_superuser'),
+        }),
+        ('Important dates', {'fields': ('last_login', 'date_joined')}),
+    )
+    filter_horizontal = ('groups',)
+    list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
+
+    def get_inlines(self, request, obj):
+        if obj is not None:
+            return (inlines.UserObjectPermissionInline, inlines.UserConfigInline)
+        return ()
+
+
+#
+# REST API tokens
+#
+
+@admin.register(Token)
+class TokenAdmin(admin.ModelAdmin):
+    form = forms.TokenAdminForm
+    list_display = [
+        'key', 'user', 'created', 'expires', 'write_enabled', 'description'
+    ]
+
+
+#
+# Permissions
+#
+
+@admin.register(ObjectPermission)
+class ObjectPermissionAdmin(admin.ModelAdmin):
+    actions = ('enable', 'disable')
+    fieldsets = (
+        (None, {
+            'fields': ('name', 'description', 'enabled')
+        }),
+        ('Actions', {
+            'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
+        }),
+        ('Objects', {
+            'fields': ('object_types',)
+        }),
+        ('Assignment', {
+            'fields': ('groups', 'users')
+        }),
+        ('Constraints', {
+            'fields': ('constraints',),
+            'classes': ('monospace',)
+        }),
+    )
+    filter_horizontal = ('object_types', 'groups', 'users')
+    form = forms.ObjectPermissionForm
+    list_display = [
+        'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
+    ]
+    list_filter = [
+        'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users'
+    ]
+    search_fields = ['actions', 'constraints', 'description', 'name']
+
+    def get_queryset(self, request):
+        return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
+
+    def list_models(self, obj):
+        return ', '.join([f"{ct}" for ct in obj.object_types.all()])
+    list_models.short_description = 'Models'
+
+    def list_users(self, obj):
+        return ', '.join([u.username for u in obj.users.all()])
+    list_users.short_description = 'Users'
+
+    def list_groups(self, obj):
+        return ', '.join([g.name for g in obj.groups.all()])
+    list_groups.short_description = 'Groups'
+
+    #
+    # Admin actions
+    #
+
+    def enable(self, request, queryset):
+        updated = queryset.update(enabled=True)
+        self.message_user(request, f"Enabled {updated} permissions")
+
+    def disable(self, request, queryset):
+        updated = queryset.update(enabled=False)
+        self.message_user(request, f"Disabled {updated} permissions")

+ 42 - 0
netbox/users/admin/filters.py

@@ -0,0 +1,42 @@
+from django.contrib import admin
+from django.contrib.contenttypes.models import ContentType
+
+from users.models import ObjectPermission
+
+__all__ = (
+    'ActionListFilter',
+    'ObjectTypeListFilter',
+)
+
+
+class ActionListFilter(admin.SimpleListFilter):
+    title = 'action'
+    parameter_name = 'action'
+
+    def lookups(self, request, model_admin):
+        options = set()
+        for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct():
+            options.update(action_list)
+        return [
+            (action, action) for action in sorted(options)
+        ]
+
+    def queryset(self, request, queryset):
+        if self.value():
+            return queryset.filter(actions=[self.value()])
+
+
+class ObjectTypeListFilter(admin.SimpleListFilter):
+    title = 'object type'
+    parameter_name = 'object_type'
+
+    def lookups(self, request, model_admin):
+        object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct()
+        content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model')
+        return [
+            (ct.pk, ct) for ct in content_types
+        ]
+
+    def queryset(self, request, queryset):
+        if self.value():
+            return queryset.filter(object_types=self.value())

+ 132 - 0
netbox/users/admin/forms.py

@@ -0,0 +1,132 @@
+from django import forms
+from django.contrib.auth.models import Group, User
+from django.contrib.admin.widgets import FilteredSelectMultiple
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import FieldError, ValidationError
+from django.db.models import Q
+
+from users.constants import OBJECTPERMISSION_OBJECT_TYPES
+from users.models import ObjectPermission, Token
+from utilities.forms.fields import ContentTypeMultipleChoiceField
+
+__all__ = (
+    'GroupAdminForm',
+    'ObjectPermissionForm',
+    'TokenAdminForm',
+)
+
+
+class GroupAdminForm(forms.ModelForm):
+    users = forms.ModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        widget=FilteredSelectMultiple('users', False)
+    )
+
+    class Meta:
+        model = Group
+        fields = ('name', 'users')
+
+    def __init__(self, *args, **kwargs):
+        super(GroupAdminForm, self).__init__(*args, **kwargs)
+
+        if self.instance.pk:
+            self.fields['users'].initial = self.instance.user_set.all()
+
+    def save_m2m(self):
+        self.instance.user_set.set(self.cleaned_data['users'])
+
+    def save(self, *args, **kwargs):
+        instance = super(GroupAdminForm, self).save()
+        self.save_m2m()
+
+        return instance
+
+
+class TokenAdminForm(forms.ModelForm):
+    key = forms.CharField(
+        required=False,
+        help_text="If no key is provided, one will be generated automatically."
+    )
+
+    class Meta:
+        fields = [
+            'user', 'key', 'write_enabled', 'expires', 'description'
+        ]
+        model = Token
+
+
+class ObjectPermissionForm(forms.ModelForm):
+    object_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES
+    )
+    can_view = forms.BooleanField(required=False)
+    can_add = forms.BooleanField(required=False)
+    can_change = forms.BooleanField(required=False)
+    can_delete = forms.BooleanField(required=False)
+
+    class Meta:
+        model = ObjectPermission
+        exclude = []
+        help_texts = {
+            'actions': 'Actions granted in addition to those listed above',
+            'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
+                           'to match all objects of this type. A list of multiple objects will result in a logical OR '
+                           'operation.'
+        }
+        labels = {
+            'actions': 'Additional actions'
+        }
+        widgets = {
+            'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'})
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Make the actions field optional since the admin form uses it only for non-CRUD actions
+        self.fields['actions'].required = False
+
+        # Order group and user fields
+        self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
+        self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
+
+        # Check the appropriate checkboxes when editing an existing ObjectPermission
+        if self.instance.pk:
+            for action in ['view', 'add', 'change', 'delete']:
+                if action in self.instance.actions:
+                    self.fields[f'can_{action}'].initial = True
+                    self.instance.actions.remove(action)
+
+    def clean(self):
+        super().clean()
+
+        object_types = self.cleaned_data.get('object_types')
+        constraints = self.cleaned_data.get('constraints')
+
+        # Append any of the selected CRUD checkboxes to the actions list
+        if not self.cleaned_data.get('actions'):
+            self.cleaned_data['actions'] = list()
+        for action in ['view', 'add', 'change', 'delete']:
+            if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
+                self.cleaned_data['actions'].append(action)
+
+        # At least one action must be specified
+        if not self.cleaned_data['actions']:
+            raise ValidationError("At least one action must be selected.")
+
+        # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
+        # returns anything; we just want to make sure the specified constraints are valid.
+        if object_types and constraints:
+            # Normalize the constraints to a list of dicts
+            if type(constraints) is not list:
+                constraints = [constraints]
+            for ct in object_types:
+                model = ct.model_class()
+                try:
+                    model.objects.filter(*[Q(**c) for c in constraints]).exists()
+                except FieldError as e:
+                    raise ValidationError({
+                        'constraints': f'Invalid filter for {model}: {e}'
+                    })

+ 49 - 0
netbox/users/admin/inlines.py

@@ -0,0 +1,49 @@
+from django.contrib import admin
+from django.contrib.auth.models import Group, User
+
+from users.models import UserConfig
+
+__all__ = (
+    'GroupObjectPermissionInline',
+    'UserConfigInline',
+    'UserObjectPermissionInline',
+)
+
+
+class ObjectPermissionInline(admin.TabularInline):
+    exclude = None
+    extra = 3
+    readonly_fields = ['object_types', 'actions', 'constraints']
+    verbose_name = 'Permission'
+    verbose_name_plural = 'Permissions'
+
+    def get_queryset(self, request):
+        return super().get_queryset(request).prefetch_related('objectpermission__object_types')
+
+    @staticmethod
+    def object_types(instance):
+        # Don't call .values_list() here because we want to reference the pre-fetched object_types
+        return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()])
+
+    @staticmethod
+    def actions(instance):
+        return ', '.join(instance.objectpermission.actions)
+
+    @staticmethod
+    def constraints(instance):
+        return instance.objectpermission.constraints
+
+
+class GroupObjectPermissionInline(ObjectPermissionInline):
+    model = Group.object_permissions.through
+
+
+class UserObjectPermissionInline(ObjectPermissionInline):
+    model = User.object_permissions.through
+
+
+class UserConfigInline(admin.TabularInline):
+    model = UserConfig
+    readonly_fields = ('data',)
+    can_delete = False
+    verbose_name = 'Preferences'

+ 2 - 1
netbox/utilities/tables.py

@@ -381,8 +381,9 @@ class TagColumn(tables.TemplateColumn):
     Display a list of tags assigned to the object.
     """
     template_code = """
+    {% load helpers %}
     {% for tag in value.all %}
-        {% include 'utilities/templatetags/tag.html' %}
+        {% tag tag url_name=url_name %}
     {% empty %}
         <span class="text-muted">&mdash;</span>
     {% endfor %}

+ 36 - 0
netbox/utilities/tests/test_tables.py

@@ -0,0 +1,36 @@
+from django.template import Context, Template
+from django.test import TestCase
+
+from dcim.models import Site
+from utilities.tables import BaseTable, TagColumn
+from utilities.testing import create_tags
+
+
+class TagColumnTable(BaseTable):
+    tags = TagColumn(url_name='dcim:site_list')
+
+    class Meta(BaseTable.Meta):
+        model = Site
+        fields = ('pk', 'name', 'tags',)
+        default_columns = fields
+
+
+class TagColumnTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        sites = [
+            Site(name=f'Site {i}', slug=f'site-{i}') for i in range(1, 6)
+        ]
+        Site.objects.bulk_create(sites)
+        for site in sites:
+            site.tags.add(*tags)
+
+    def test_tagcolumn(self):
+        template = Template('{% load render_table from django_tables2 %}{% render_table table %}')
+        context = Context({
+            'table': TagColumnTable(Site.objects.all(), orderable=False)
+        })
+        template.render(context)

+ 1 - 1
netbox/virtualization/tables.py

@@ -18,7 +18,7 @@ __all__ = (
 
 VMINTERFACE_BUTTONS = """
 {% if perms.ipam.add_ipaddress %}
-    <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address">
+    <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-sm btn-success" title="Add IP Address">
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
     </a>
 {% endif %}