From e11e8a5d6436f770e8157bdbd20123e2878a5c7b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 09:15:25 -0500 Subject: [PATCH 001/104] Fixes #8213: Fix ValueError exception under prefix IP addresses view --- docs/release-notes/version-3.1.md | 4 ++++ netbox/ipam/views.py | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 29213a4c5..b523ab8c7 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,6 +2,10 @@ ## v3.1.5 (FUTURE) +### Bug Fixes + +* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view + --- ## v3.1.4 (2022-01-03) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 55ac284d1..c79a58dd6 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -505,9 +505,7 @@ 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').prefetch_related( - 'vrf', 'role', 'tenant', - ) + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant') def prep_table_data(self, request, queryset, parent): show_available = bool(request.GET.get('show_available', 'true') == 'true') From 2fe02ddb1f88a93cba82b04bbd3c2caa0425e5b6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 09:32:41 -0500 Subject: [PATCH 002/104] Add tests for IPAM object children views --- netbox/ipam/tests/test_views.py | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 022ea13c3..4f0f9a214 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,5 +1,7 @@ import datetime +from django.test import override_settings +from django.urls import reverse from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site @@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_aggregate_prefixes(self): + rir = RIR.objects.first() + aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir) + prefixes = ( + Prefix(prefix=IPNetwork('192.168.1.0/24')), + Prefix(prefix=IPNetwork('192.168.2.0/24')), + Prefix(prefix=IPNetwork('192.168.3.0/24')), + ) + Prefix.objects.bulk_create(prefixes) + self.assertEqual(aggregate.get_child_prefixes().count(), 3) + + url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Role @@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_prefixes(self): + prefixes = ( + Prefix(prefix=IPNetwork('192.168.0.0/16')), + Prefix(prefix=IPNetwork('192.168.1.0/24')), + Prefix(prefix=IPNetwork('192.168.2.0/24')), + Prefix(prefix=IPNetwork('192.168.3.0/24')), + ) + Prefix.objects.bulk_create(prefixes) + self.assertEqual(prefixes[0].get_child_prefixes().count(), 3) + + url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_ipranges(self): + prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16')) + ip_ranges = ( + IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99), + IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99), + IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99), + ) + IPRange.objects.bulk_create(ip_ranges) + self.assertEqual(prefix.get_child_ranges().count(), 3) + + url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_ipaddresses(self): + prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16')) + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.1/16')), + IPAddress(address=IPNetwork('192.168.0.2/16')), + IPAddress(address=IPNetwork('192.168.0.3/16')), + ) + IPAddress.objects.bulk_create(ip_addresses) + self.assertEqual(prefix.get_child_ips().count(), 3) + + url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPRange @@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_iprange_ipaddresses(self): + iprange = IPRange.objects.create( + start_address=IPNetwork('192.168.0.1/24'), + end_address=IPNetwork('192.168.0.100/24'), + size=99 + ) + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.1/24')), + IPAddress(address=IPNetwork('192.168.0.2/24')), + IPAddress(address=IPNetwork('192.168.0.3/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + self.assertEqual(iprange.get_child_ips().count(), 3) + + url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPAddress From 8c8774cd2fd5e826c6787b415af2136429b4eecb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 13:24:15 -0500 Subject: [PATCH 003/104] Fixes #8226: Honor return URL after populating a device bay --- docs/release-notes/version-3.1.md | 1 + netbox/dcim/views.py | 3 ++- netbox/templates/dcim/devicebay_populate.html | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index b523ab8c7..963aaad23 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view +* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay --- diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7048ae63e..cee516f5c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2035,8 +2035,9 @@ class DeviceBayPopulateView(generic.ObjectEditView): device_bay.installed_device = form.cleaned_data['installed_device'] device_bay.save() messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay)) + return_url = self.get_return_url(request) - return redirect('dcim:device', pk=device_bay.device.pk) + return redirect(return_url) return render(request, 'dcim/devicebay_populate.html', { 'device_bay': device_bay, diff --git a/netbox/templates/dcim/devicebay_populate.html b/netbox/templates/dcim/devicebay_populate.html index d0f47921a..237227277 100644 --- a/netbox/templates/dcim/devicebay_populate.html +++ b/netbox/templates/dcim/devicebay_populate.html @@ -4,7 +4,7 @@ {% render_errors form %} {% block content %} -
+ {% csrf_token %}
From ea961ba8f219e9a52b8a10c6a832faed1ed323c3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 13:49:07 -0500 Subject: [PATCH 004/104] Fixes #8224: Fix KeyError exception when creating FHRP group with IP address and protocol "other" --- docs/release-notes/version-3.1.md | 1 + netbox/ipam/constants.py | 1 + netbox/ipam/forms/models.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 963aaad23..4e8446b41 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view +* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other" * [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay --- diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index b19d4061b..ab88dfc1a 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = { FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, + FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP, } diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index c5e3146e9..0f85a95b1 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -580,7 +580,7 @@ class FHRPGroupForm(CustomFieldModelForm): vrf=self.cleaned_data['ip_vrf'], address=self.cleaned_data['ip_address'], status=self.cleaned_data['ip_status'], - role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']], + role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP), assigned_object=instance ) ipaddress.save() From 662cafe416b7dd0ed2a23735453131a3361d931d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 15:01:16 -0500 Subject: [PATCH 005/104] Form widgets & style cleanup --- netbox/extras/forms/models.py | 18 ++++++++++++++---- netbox/ipam/forms/models.py | 3 +-- netbox/project-static/dist/netbox-light.css | Bin 493807 -> 493807 bytes netbox/project-static/dist/netbox-print.css | Bin 1624275 -> 1624275 bytes netbox/project-static/styles/theme-light.scss | 2 -- netbox/utilities/forms/fields.py | 4 ++-- netbox/utilities/forms/widgets.py | 10 ---------- .../templates/widgets/select_contenttype.html | 1 - 8 files changed, 17 insertions(+), 21 deletions(-) delete mode 100644 netbox/utilities/templates/widgets/select_contenttype.html diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 1e619ebec..89ab7aa19 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -7,8 +7,8 @@ from extras.models import * from extras.utils import FeatureQuery from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, - ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, + add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, + DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup @@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) + widgets = { + 'type': StaticSelect(), + 'filter_logic': StaticSelect(), + } class CustomLinkForm(BootstrapMixin, forms.ModelForm): @@ -57,6 +61,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): ('Templates', ('link_text', 'link_url')), ) widgets = { + 'button_class': StaticSelect(), 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), } @@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): model = Webhook fields = '__all__' fieldsets = ( - ('Webhook', ('name', 'enabled')), - ('Assigned Models', ('content_types',)), + ('Webhook', ('name', 'content_types', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ('HTTP Request', ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', @@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): ('Conditions', ('conditions',)), ('SSL', ('ssl_verification', 'ca_file_path')), ) + labels = { + 'type_create': 'Creations', + 'type_update': 'Updates', + 'type_delete': 'Deletions', + } widgets = { + 'http_method': StaticSelect(), 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}), } diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 0f85a95b1..4ed8aa267 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -628,8 +628,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): class VLANGroupForm(CustomFieldModelForm): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False, - widget=StaticSelect + required=False ) region = DynamicModelChoiceField( queryset=Region.objects.all(), diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 23dc8d3821e58e7264fe4bd3109dea298dc227bf..215f986bb1045d51eac763de5aac1e0e23d59cad 100644 GIT binary patch delta 216 zcmaDqQSSXjxeaCU(-l^;vNzYpx7Wrq0x=U1GXpWp_S$$>{a}zR+jPZQtg_qBZ(`la z0pV}h#j3D<-ELM9Sunr-#0}Q%CvLFuWlf*2#>75d-kn`zngt{8IO;>eh z&j6{EoBm-3Gu!ly&g`=%C){V7yx|#~ezHT?Gh%8j3{lpE{?I&)q@nubyRbXPDF7M8+G0lRJck-Pu-pLc5vrSiZ zX3v1Covb)ZY`VY^W{&9_o!MtkE_li|IpG=4Ous$nGgAqiZrAn19hVSGEoqP#9W4+Fn1+^l>(*E>Z1V|Sfv3G&(rzdza(bU2voPJ zE{=BSZvL++nr44FRi`M7EYu@Xb#^cYMpjW^q3)qjSmZKHncRZh4-132s6`K}eAdYY z6==OP6>2JvLiN;Z!1uUhkkY!Hw2N^5!M4Dhp;2f~>qN|avK5Kg6L#8Ohi02a;P#Se z;_{0|toA-0$X(|GtRJEO_bSOHlQC2s9MFBwjWCbny#O CaFav; delta 438 zcmZ9`JxD@P7zSX}+qrtZH?JP;9PEcef()XX`f;#=gixp^8wE4kifC$TiN*?vcZ)kU zH53$QNt%o3hn5HeA)F#;h=zWw&!~uoXZXGsUf!#X{MAN&dEYgZWQBo3zA&?zO1g-f zc!);o2qQZ25+5;$pP0lV^&~)S5+os&O7b1?U5d#Z=O{Px09vPbK*h6stnnvno90nC z^W0Qh3w$w)5gu$`?K|NlOd_N~^__^7Zk)H;41*Ojj9gl3NZ2xhi4PHmo%XkEI&!o) z5!6wB76x_=*#c+JW8uBmWg(Z<84g~=EY@$#COF*ks^M2L>B8K#o8h7=qBt!vzbd_n z`>$tQT~@`D_M4Am_sA1MPm!6bUz5ig)~?;YVl2l0jLB7njU_``*w$nx&JF2Bmm!;r wJs}30$#^YnuU)jZ180Wx)$-fgJ{^gQ`L8U3hdP;oconsole server port - This attribute can be used to reference the relevant API endpoint for a particular ContentType. - """ - option_template_name = 'widgets/select_contenttype.html' - - class SelectSpeedWidget(forms.NumberInput): """ Speed field with dropdown selections for convenience. diff --git a/netbox/utilities/templates/widgets/select_contenttype.html b/netbox/utilities/templates/widgets/select_contenttype.html deleted file mode 100644 index 04c42c371..000000000 --- a/netbox/utilities/templates/widgets/select_contenttype.html +++ /dev/null @@ -1 +0,0 @@ - From fa1e28e860c4bdb3e585a968bd248a2ac666e1f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 17:03:41 -0500 Subject: [PATCH 006/104] Initial work on #7006 --- netbox/extras/api/customfields.py | 15 +- netbox/extras/choices.py | 2 + netbox/extras/forms/customfields.py | 13 +- netbox/extras/forms/models.py | 2 +- .../migrations/0068_custom_object_field.py | 18 ++ netbox/extras/models/customfields.py | 41 +++- netbox/extras/tests/test_customfields.py | 231 +++++++++--------- netbox/extras/tests/test_forms.py | 14 +- netbox/netbox/models.py | 15 +- .../templates/inc/panels/custom_fields.html | 2 + 10 files changed, 224 insertions(+), 129 deletions(-) create mode 100644 netbox/extras/migrations/0068_custom_object_field.py diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5cb1fc276..f2f4b69a6 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.fields import Field +from extras.choices import CustomFieldTypeChoices from extras.models import CustomField @@ -44,9 +45,17 @@ class CustomFieldsDataField(Field): return self._custom_fields def to_representation(self, obj): - return { - cf.name: obj.get(cf.name) for cf in self._get_custom_fields() - } + # TODO: Fix circular import + from utilities.api import get_serializer_for_model + data = {} + for cf in self._get_custom_fields(): + value = cf.deserialize(obj.get(cf.name)) + if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + value = serializer(value, context=self.parent.context).data + data[cf.name] = value + + return data def to_internal_value(self, data): # If updating an existing instance, start with existing custom_field_data diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index cf64bc005..5c18f2705 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -16,6 +16,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_JSON = 'json' TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' + TYPE_OBJECT = 'object' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), + (TYPE_OBJECT, 'NetBox object'), ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index d58e6ce65..bd28a30e7 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -20,7 +20,7 @@ class CustomFieldsMixin: Extend a Form to include custom field support. """ def __init__(self, *args, **kwargs): - self.custom_fields = [] + self.custom_fields = {} super().__init__(*args, **kwargs) @@ -49,7 +49,7 @@ class CustomFieldsMixin: self.fields[field_name] = self._get_form_field(customfield) # Annotate the field in the list of CustomField form fields - self.custom_fields.append(field_name) + self.custom_fields[field_name] = customfield class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): @@ -70,12 +70,15 @@ class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): def clean(self): # Save custom field data on instance - for cf_name in self.custom_fields: + for cf_name, customfield in self.custom_fields.items(): key = cf_name[3:] # Strip "cf_" from field name value = self.cleaned_data.get(cf_name) - empty_values = self.fields[cf_name].empty_values + # Convert "empty" values to null - self.instance.custom_field_data[key] = value if value not in empty_values else None + if value in self.fields[cf_name].empty_values: + self.instance.custom_field_data[key] = None + else: + self.instance.custom_field_data[key] = customfield.serialize(value) return super().clean() diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index d75214722..55e58a7f2 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -35,7 +35,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): model = CustomField fields = '__all__' fieldsets = ( - ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), + ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), ('Assigned Models', ('content_types',)), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), diff --git a/netbox/extras/migrations/0068_custom_object_field.py b/netbox/extras/migrations/0068_custom_object_field.py new file mode 100644 index 000000000..0fa50a84d --- /dev/null +++ b/netbox/extras/migrations/0068_custom_object_field.py @@ -0,0 +1,18 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0067_configcontext_cluster_types'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='object_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 8c817ad33..fa65cbdee 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, + CSVChoiceField, DatePicker, DynamicModelChoiceField, LaxURLField, StaticSelectMultiple, StaticSelect, + add_blank_choice, ) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -50,8 +51,17 @@ class CustomField(ChangeLoggedModel): type = models.CharField( max_length=50, choices=CustomFieldTypeChoices, - default=CustomFieldTypeChoices.TYPE_TEXT + default=CustomFieldTypeChoices.TYPE_TEXT, + help_text='The type of data this custom field holds' ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + blank=True, + null=True, + help_text='The type of NetBox object this field maps to (for object fields)' + ) + name = models.CharField( max_length=50, unique=True, @@ -122,7 +132,6 @@ class CustomField(ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) - objects = CustomFieldManager() class Meta: @@ -234,6 +243,23 @@ class CustomField(ChangeLoggedModel): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) + def serialize(self, value): + """ + Prepare a value for storage as JSON data. + """ + if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + return value.pk + return value + + def deserialize(self, value): + """ + Convert JSON data to a Python object suitable for the field type. + """ + if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + model = self.object_type.model_class() + return model.objects.filter(pk=value).first() + return value + def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. @@ -300,6 +326,15 @@ class CustomField(ChangeLoggedModel): elif self.type == CustomFieldTypeChoices.TYPE_JSON: field = forms.JSONField(required=required, initial=initial) + # Object + elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: + model = self.object_type.model_class() + field = DynamicModelChoiceField( + queryset=model.objects.all(), + required=required, + initial=initial + ) + # Text else: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index fdabe0fcf..df803ce1b 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -8,6 +8,7 @@ from dcim.forms import SiteCSVForm from dcim.models import Site, Rack from extras.choices import * from extras.models import CustomField +from ipam.models import VLAN from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -201,76 +202,67 @@ class CustomFieldAPITest(APITestCase): def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Site) - # Text custom field - cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') - cls.cf_text.save() - cls.cf_text.content_types.set([content_type]) + # Create some VLANs + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + ) + VLAN.objects.bulk_create(vlans) - # Long text custom field - cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') - cls.cf_longtext.save() - cls.cf_longtext.content_types.set([content_type]) + custom_fields = ( + CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), + CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), + CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), + CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), + CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), + CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'), + CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=( + 'Foo', 'Bar', 'Baz' + )), + CustomField( + type=CustomFieldTypeChoices.TYPE_OBJECT, + name='object_field', + object_type=ContentType.objects.get_for_model(VLAN), + default=vlans[0].pk, + ), + ) + for cf in custom_fields: + cf.save() + cf.content_types.set([content_type]) - # Integer custom field - cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) - cls.cf_integer.save() - cls.cf_integer.content_types.set([content_type]) - - # Boolean custom field - cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False) - cls.cf_boolean.save() - cls.cf_boolean.content_types.set([content_type]) - - # Date custom field - cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01') - cls.cf_date.save() - cls.cf_date.content_types.set([content_type]) - - # URL custom field - cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1') - cls.cf_url.save() - cls.cf_url.content_types.set([content_type]) - - # JSON custom field - cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}') - cls.cf_json.save() - cls.cf_json.content_types.set([content_type]) - - # Select custom field - cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) - cls.cf_select.default = 'Foo' - cls.cf_select.save() - cls.cf_select.content_types.set([content_type]) - - # Create some sites - cls.sites = ( + # Create some sites *after* creating the custom fields. This ensures that + # default values are not set for the assigned objects. + sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), ) - Site.objects.bulk_create(cls.sites) + Site.objects.bulk_create(sites) # Assign custom field values for site 2 - cls.sites[1].custom_field_data = { - cls.cf_text.name: 'bar', - cls.cf_longtext.name: 'DEF', - cls.cf_integer.name: 456, - cls.cf_boolean.name: True, - cls.cf_date.name: '2020-01-02', - cls.cf_url.name: 'http://example.com/2', - cls.cf_json.name: '{"foo": 1, "bar": 2}', - cls.cf_select.name: 'Bar', + sites[1].custom_field_data = { + custom_fields[0].name: 'bar', + custom_fields[1].name: 'DEF', + custom_fields[2].name: 456, + custom_fields[3].name: True, + custom_fields[4].name: '2020-01-02', + custom_fields[5].name: 'http://example.com/2', + custom_fields[6].name: '{"foo": 1, "bar": 2}', + custom_fields[7].name: 'Bar', + custom_fields[8].name: vlans[1].pk, } - cls.sites[1].save() + sites[1].save() def test_get_single_object_without_custom_field_data(self): """ Validate that custom fields are present on an object even if it has no values defined. """ - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk}) + site1 = Site.objects.get(name='Site 1') + url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[0].name) + self.assertEqual(response.data['name'], site1.name) self.assertEqual(response.data['custom_fields'], { 'text_field': None, 'longtext_field': None, @@ -280,18 +272,20 @@ class CustomFieldAPITest(APITestCase): 'url_field': None, 'json_field': None, 'choice_field': None, + 'object_field': None, }) def test_get_single_object_with_custom_field_data(self): """ Validate that custom fields are present and correctly set for an object with values defined. """ - site2_cfvs = self.sites[1].custom_field_data - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + site2_cfvs = site2.custom_field_data + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[1].name) + self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) @@ -300,11 +294,15 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) + self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) def test_create_single_object_with_defaults(self): """ Create a new site with no specified custom field values and check that it received the default values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = { 'name': 'Site 3', 'slug': 'site-3', @@ -317,25 +315,27 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) def test_create_single_object_with_values(self): """ @@ -353,6 +353,7 @@ class CustomFieldAPITest(APITestCase): 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', + 'object_field': VLAN.objects.get(vid=2).pk, }, } url = reverse('dcim-api:site-list') @@ -372,6 +373,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) + self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -383,12 +385,16 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) def test_create_multiple_objects_with_defaults(self): """ - Create three news sites with no specified custom field values and check that each received + Create three new sites with no specified custom field values and check that each received the default custom field values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = ( { 'name': 'Site 3', @@ -414,25 +420,27 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) def test_create_multiple_objects_with_values(self): """ @@ -447,6 +455,7 @@ class CustomFieldAPITest(APITestCase): 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', + 'object_field': VLAN.objects.get(vid=2).pk, } data = ( { @@ -501,15 +510,15 @@ class CustomFieldAPITest(APITestCase): Update an object with existing custom field values. Ensure that only the updated custom field values are modified. """ - site = self.sites[1] - original_cfvs = {**site.custom_field_data} + site2 = Site.objects.get(name='Site 2') + original_cfvs = {**site2.custom_field_data} data = { 'custom_fields': { 'text_field': 'ABCD', 'number_field': 1234, }, } - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') response = self.client.patch(url, data, format='json', **self.header) @@ -527,23 +536,25 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) # Validate database data - site.refresh_from_db() - self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field']) - self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) - self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) - self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) - self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) - self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) + site2.refresh_from_db() + self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) + self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field']) + self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) + self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) + self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) + self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field']) def test_minimum_maximum_values_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_integer.validation_minimum = 10 - self.cf_integer.validation_maximum = 20 - self.cf_integer.save() + cf_integer = CustomField.objects.get(name='number_field') + cf_integer.validation_minimum = 10 + cf_integer.validation_maximum = 20 + cf_integer.save() data = {'custom_fields': {'number_field': 9}} response = self.client.patch(url, data, format='json', **self.header) @@ -558,11 +569,13 @@ class CustomFieldAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) def test_regex_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters - self.cf_text.save() + cf_text = CustomField.objects.get(name='text_field') + cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters + cf_text.save() data = {'custom_fields': {'text_field': 'ABC123'}} response = self.client.patch(url, data, format='json', **self.header) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cf28a46e7..e8b16d7ab 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -38,10 +38,20 @@ class CustomFieldModelFormTest(TestCase): cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) cf_select.content_types.set([obj_type]) - cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=CHOICES) + cf_multiselect = CustomField.objects.create( + name='multiselect', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=CHOICES + ) cf_multiselect.content_types.set([obj_type]) + cf_object = CustomField.objects.create( + name='object', + type=CustomFieldTypeChoices.TYPE_OBJECT, + object_type=ContentType.objects.get_for_model(Site) + ) + cf_object.content_types.set([obj_type]) + def test_empty_values(self): """ Test that empty custom field values are stored as null diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 91240ee90..3e6ebd8b2 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -1,5 +1,4 @@ import logging -from collections import OrderedDict from django.contrib.contenttypes.fields import GenericRelation from django.core.serializers.json import DjangoJSONEncoder @@ -99,16 +98,20 @@ class CustomFieldsMixin(models.Model): """ from extras.models import CustomField - fields = CustomField.objects.get_for_model(self) - return OrderedDict([ - (field, self.custom_field_data.get(field.name)) for field in fields - ]) + data = {} + for field in CustomField.objects.get_for_model(self): + value = self.custom_field_data.get(field.name) + data[field] = field.deserialize(value) + + return data def clean(self): super().clean() from extras.models import CustomField - custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} + custom_fields = { + cf.name: cf for cf in CustomField.objects.get_for_model(self) + } # Validate all field values for field_name, value in self.custom_field_data.items(): diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index b48a43f1c..46636504e 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -24,6 +24,8 @@
{{ value|render_json }}
{% elif field.type == 'multiselect' and value %} {{ value|join:", " }} + {% elif field.type == 'object' and value %} + {{ value }} {% elif value is not None %} {{ value }} {% elif field.required %} From 954d81147e7d65ad099fc122ab2ea790b0eb0e0b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 17:07:37 -0500 Subject: [PATCH 007/104] Reindex migrations --- ...{0068_custom_object_field.py => 0069_custom_object_field.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/extras/migrations/{0068_custom_object_field.py => 0069_custom_object_field.py} (89%) diff --git a/netbox/extras/migrations/0068_custom_object_field.py b/netbox/extras/migrations/0069_custom_object_field.py similarity index 89% rename from netbox/extras/migrations/0068_custom_object_field.py rename to netbox/extras/migrations/0069_custom_object_field.py index 0fa50a84d..720e21edc 100644 --- a/netbox/extras/migrations/0068_custom_object_field.py +++ b/netbox/extras/migrations/0069_custom_object_field.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0067_configcontext_cluster_types'), + ('extras', '0068_configcontext_cluster_types'), ] operations = [ From 0a22b3990fe93c27f19dfe1797de7ce07ffd109f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 20:42:44 -0500 Subject: [PATCH 008/104] #7450: Clean up footer and navbar styles --- netbox/project-static/dist/netbox-dark.css | Bin 789153 -> 789142 bytes netbox/project-static/dist/netbox-light.css | Bin 493807 -> 493753 bytes netbox/project-static/dist/netbox-print.css | Bin 1624275 -> 1624333 bytes netbox/project-static/styles/netbox.scss | 18 +++--- netbox/project-static/styles/theme-dark.scss | 2 +- netbox/project-static/styles/theme-light.scss | 2 + netbox/templates/base/layout.html | 2 +- netbox/templates/extras/report.html | 55 +++++++++++------- netbox/templates/extras/report_result.html | 2 +- 9 files changed, 46 insertions(+), 35 deletions(-) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index e711685bf40042d2b0918218882f0acba69a0d49..4cb2f191d5dcdfc2729ee80dd254d9d59853a8bd 100644 GIT binary patch delta 296 zcmZ2D)nM9GgN7Ey7N!>F7M3lnk?qr2vKh6eCzP|YPY-Bg<(ux%!OA<`wwKiqiQCc3 z%D4T0J8J~vboDew(dqxUazeE%=w{`gzNVA4gU!&~Fv&1w`u%>^>C=ItLX6eZ|7I~7 zLQDnxVl$&Sz)kWNr)8*%I zvu>|n#QlVKI?Dr2q3H@`%2E)==}cGH#Vxk|$!=~=x9J)nMUNgN7Ey7N!>F7M3lnk?qr8hOuf*Pbg<)pB~W0$~WDigOzu>Z7-`K61St5 zm2dn1cGd{S=@G4rqSOCx<%DWk(9Oy}eN8882U}uFl4(-P^!xp+)29PPg{D6YV>N=9 z3*?Hl8+5Y*F&hxG12G2>a{@8fc7ty24`S0Bd^x$M^Yw9ZOx|R#Jbl3mZl39<7I8~% z&tJs-l6QOI2JUo@=@kX6BHPV&a<5`yOUp^Euri!{aH+`jx1ZQ_rccHnE{xVEol;t^z=zLuRwXu5nEGyiloQFcLgWfMzN15@+q4wE$1F5mGl^Lc!tzdH7MNZc4dhzIGxJWDh-T((e;CIM z#4JF}y8U4so7Us$4$bWBjJ4AXoY+;jJ3F!Sb4_=NWfPv9u$ygrkPrJs7RKu7AM#lw zrmx<_%DG)Oko`B~^he)VB-pLfa#Aa-45u5MXO-J-6U@F`fBMFBHc`g3=~3>?Qqw;- Nu^-P& delta 215 zcmdlvQSSXjxrP?T7N#xCYoj>~z`!DTdSf)R%J%Eg%p8meu>vb*(e1xtm|c;?7^9iR z+hgLGftUq|S+~c;v8{YO`GG&%^y*WroGeM{nzhs8o!C{k8#%G_b8V0EVZX?-Jvfm4 zE93UWVD{7c(^K4;1*XR|vmc*6r;S}`dcGVJ|MU|ZSR{mN9dc7sG7}XFiZb&`s`W|| nlXRy$1hY%R4QB=#KE0lkRf5$jFF!AJdPNJn;&#gx_68OJIbTLo diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 2d702b89a02932ba5f1efb8d489493e8cb39eaab..f292f7bd8cf58bac5bf3d25763017d89b222f6d3 100644 GIT binary patch delta 299 zcmcaSF}ZhAazhJa3sVbo3rh=Y3tJ0&3r7oQ3)dFzm?zV}d$IFQZ-2-wy1n5E_ZF7v ztby#J+r3|MM>9>&a^~lqe*HDKbo+(Z+(66&#JoVv2gLk9EC9rUKr95r!aytn#G*hf zw*A6uaewaV4o4-}r{A6^A~HR}TY_b})(`Oo+h2YcHpe6Z?{YEO<%WNf^E9OGH$S;T+`1jz!(Es*jGWz|*1*>QrlkDLF9}I=x4a)?-Vf#"); $navbar-light-toggler-border-color: $gray-700; diff --git a/netbox/project-static/styles/theme-light.scss b/netbox/project-static/styles/theme-light.scss index 22cc48108..0ca85319b 100644 --- a/netbox/project-static/styles/theme-light.scss +++ b/netbox/project-static/styles/theme-light.scss @@ -22,6 +22,8 @@ $theme-colors: map-merge($theme-colors, $theme-color-addons); $light: $gray-200; +$navbar-light-color: $gray-100; + $card-cap-color: $gray-800; $accordion-bg: transparent; diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 7b1597bf0..1959ef38d 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -20,7 +20,7 @@
{# Top bar #} -
- {% table_config_form table %} {% endblock %} + +{% block modals %} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index 210cff812..8663c158f 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -35,5 +35,8 @@ - {% table_config_form table %} {% endblock %} + +{% block modals %} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index e2f77756c..ae5d3cf74 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -35,5 +35,8 @@ - {% table_config_form table %} {% endblock %} + +{% block modals %} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index af80578a0..3d5e0c4c0 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -35,5 +35,8 @@ - {% table_config_form table %} {% endblock %} + +{% block modals %} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 18fcbb569..21756a36a 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -37,5 +37,8 @@ - {% table_config_form table %} {% endblock %} + +{% block modals %} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/vlan/interfaces.html b/netbox/templates/ipam/vlan/interfaces.html index acba983aa..3ce00631f 100644 --- a/netbox/templates/ipam/vlan/interfaces.html +++ b/netbox/templates/ipam/vlan/interfaces.html @@ -5,13 +5,14 @@
{% csrf_token %} {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %} -
{% include 'htmx/table.html' %}
-
+{% endblock content %} + +{% block modals %} {% table_config_form table %} -{% endblock %} +{% endblock modals %} diff --git a/netbox/templates/ipam/vlan/vminterfaces.html b/netbox/templates/ipam/vlan/vminterfaces.html index aff559393..fcd207894 100644 --- a/netbox/templates/ipam/vlan/vminterfaces.html +++ b/netbox/templates/ipam/vlan/vminterfaces.html @@ -5,13 +5,14 @@
{% csrf_token %} {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %} -
{% include 'htmx/table.html' %}
-
+{% endblock content %} + +{% block modals %} {% table_config_form table %} -{% endblock %} +{% endblock modals %} diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html index ab774a29c..700006196 100644 --- a/netbox/templates/virtualization/cluster/devices.html +++ b/netbox/templates/virtualization/cluster/devices.html @@ -6,13 +6,11 @@
{% csrf_token %} {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %} -
{% include 'htmx/table.html' %}
-
{% if perms.virtualization.change_cluster %} @@ -23,5 +21,8 @@
+{% endblock content %} + +{% block modals %} {% table_config_form table %} -{% endblock %} +{% endblock modals %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html index 7681e3413..5b0359e07 100644 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -6,13 +6,11 @@
{% csrf_token %} {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %} -
{% include 'htmx/table.html' %}
-
{% if perms.virtualization.change_virtualmachine %} @@ -28,5 +26,8 @@
+{% endblock content %} + +{% block modals %} {% table_config_form table %} -{% endblock %} +{% endblock modals %} diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index 6b3e70c7f..5f6ab52ad 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -37,5 +37,8 @@
+{% endblock content %} + +{% block modals %} {% table_config_form table %} -{% endblock %} +{% endblock modals %} From 511aedd5dba206a781897d70751c32df7571fcdf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 11:39:58 -0500 Subject: [PATCH 012/104] Omit table configuration form from rack elevations view --- netbox/templates/dcim/rack_elevation_list.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 312b543a6..87a047900 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -73,3 +73,5 @@ {% endblock content-wrapper %} + +{% block modals %}{% endblock %} From 443b4ccc573f07f582f0c9ca8485f15d3517c71d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 11:23:11 -0500 Subject: [PATCH 013/104] Initial work on #8231 --- netbox/ipam/views.py | 1 - netbox/netbox/views/generic.py | 20 +++++++++++++++---- netbox/templates/generic/object.html | 6 +++++- netbox/templates/generic/object_delete.html | 19 ++++++++++++------ netbox/templates/htmx/delete_form.html | 20 +++++++++++++++++++ netbox/templates/inc/htmx_modal.html | 7 +++++++ netbox/templates/ipam/prefix_delete.html | 5 ----- .../utilities/templates/buttons/delete.html | 10 ++++++++-- 8 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 netbox/templates/htmx/delete_form.html create mode 100644 netbox/templates/inc/htmx_modal.html delete mode 100644 netbox/templates/ipam/prefix_delete.html diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c79a58dd6..38b30e9cc 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -529,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView): class PrefixDeleteView(generic.ObjectDeleteView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_delete.html' class PrefixBulkImportView(generic.BulkImportView): diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index feff2ca39..74f8f325b 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -10,6 +10,7 @@ from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe @@ -430,10 +431,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): obj = self.get_object(kwargs) form = ConfirmationForm(initial=request.GET) + # If this is an HTMX request, return only the rendered deletion form as modal content + if is_htmx(request): + viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete' + form_url = reverse(viewname, kwargs={'pk': obj.pk}) + return render(request, 'htmx/delete_form.html', { + 'object': obj, + 'object_type': self.queryset.model._meta.verbose_name, + 'form': form, + 'form_url': form_url, + }) + return render(request, self.template_name, { - 'obj': obj, + 'object': obj, + 'object_type': self.queryset.model._meta.verbose_name, 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request, obj), }) @@ -466,9 +478,9 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): logger.debug("Form validation failed") return render(request, self.template_name, { - 'obj': obj, + 'object': obj, + 'object_type': self.queryset.model._meta.verbose_name, 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request, obj), }) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 40c0e09ce..4d616f944 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -100,4 +100,8 @@
{% block content %}{% endblock %}
-{% endblock %} +{% endblock content-wrapper %} + +{% block modals %} + {% include 'inc/htmx_modal.html' %} +{% endblock modals %} diff --git a/netbox/templates/generic/object_delete.html b/netbox/templates/generic/object_delete.html index 85cedd29c..d0603ace0 100644 --- a/netbox/templates/generic/object_delete.html +++ b/netbox/templates/generic/object_delete.html @@ -1,9 +1,16 @@ -{% extends 'generic/confirmation_form.html' %} +{% extends 'base/layout.html' %} {% load form_helpers %} -{% block title %}Delete {{ obj_type }}?{% endblock %} +{% block title %}Delete {{ object_type }}?{% endblock %} -{% block message %} -

Are you sure you want to delete {{ obj_type }} {{ obj }}?

- {% block message_extra %}{% endblock %} -{% endblock message %} +{% block header %}{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/netbox/templates/htmx/delete_form.html b/netbox/templates/htmx/delete_form.html new file mode 100644 index 000000000..fc1cbe0a0 --- /dev/null +++ b/netbox/templates/htmx/delete_form.html @@ -0,0 +1,20 @@ +{% load form_helpers %} + +
+ {% csrf_token %} + + + +
diff --git a/netbox/templates/inc/htmx_modal.html b/netbox/templates/inc/htmx_modal.html new file mode 100644 index 000000000..771f5d595 --- /dev/null +++ b/netbox/templates/inc/htmx_modal.html @@ -0,0 +1,7 @@ + diff --git a/netbox/templates/ipam/prefix_delete.html b/netbox/templates/ipam/prefix_delete.html deleted file mode 100644 index eb7a22d3c..000000000 --- a/netbox/templates/ipam/prefix_delete.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'generic/object_delete.html' %} - -{% block message_extra %} -

Note: This will not delete any child prefixes or IP addresses.

-{% endblock %} diff --git a/netbox/utilities/templates/buttons/delete.html b/netbox/utilities/templates/buttons/delete.html index 6fe3fe7d8..a027edeec 100644 --- a/netbox/utilities/templates/buttons/delete.html +++ b/netbox/utilities/templates/buttons/delete.html @@ -1,3 +1,9 @@ - -  Delete + +  Delete From ccda73494f5f4b104f280865b5c99eb43014cb0e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 14:57:56 -0500 Subject: [PATCH 014/104] Center modal dialog vertically --- netbox/templates/inc/htmx_modal.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/inc/htmx_modal.html b/netbox/templates/inc/htmx_modal.html index 771f5d595..d15e5b799 100644 --- a/netbox/templates/inc/htmx_modal.html +++ b/netbox/templates/inc/htmx_modal.html @@ -1,5 +1,5 @@ {% plugin_right_page object %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index a0482aa1d..0312949d1 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -40,13 +40,14 @@ class TenantSerializer(PrimaryModelSerializer): vlan_count = serializers.IntegerField(read_only=True) vrf_count = serializers.IntegerField(read_only=True) cluster_count = serializers.IntegerField(read_only=True) + cable_count = serializers.IntegerField(read_only=True) class Meta: model = Tenant fields = [ 'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', - 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', + 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', 'cable_count', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 50b188b5f..f4b8abbf1 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,7 +1,7 @@ from rest_framework.routers import APIRootView from circuits.models import Circuit -from dcim.models import Device, Rack, Site +from dcim.models import Device, Rack, Site, Cable from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filtersets @@ -47,7 +47,8 @@ class TenantViewSet(CustomFieldModelViewSet): site_count=count_related(Site, 'tenant'), virtualmachine_count=count_related(VirtualMachine, 'tenant'), vlan_count=count_related(VLAN, 'tenant'), - vrf_count=count_related(VRF, 'tenant') + vrf_count=count_related(VRF, 'tenant'), + cable_count=count_related(Cable, 'tenant') ) serializer_class = serializers.TenantSerializer filterset_class = filtersets.TenantFilterSet diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index c848de47f..b0f550304 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from circuits.models import Circuit -from dcim.models import Site, Rack, Device, RackReservation +from dcim.models import Site, Rack, Device, RackReservation, Cable from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic from utilities.tables import paginate_table @@ -112,6 +112,7 @@ class TenantView(generic.ObjectView): 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(), } return { From 184b1055dc6121417f0ee726e13dd16b9de6fc75 Mon Sep 17 00:00:00 2001 From: Jason Yates Date: Fri, 7 Jan 2022 20:17:43 +0000 Subject: [PATCH 039/104] Fixes #8285 - Cluster count missing from tenant api output --- netbox/tenancy/api/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 50b188b5f..c336c88b6 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -7,7 +7,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filtersets from tenancy.models import * from utilities.utils import count_related -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, Cluster from . import serializers @@ -47,7 +47,8 @@ class TenantViewSet(CustomFieldModelViewSet): site_count=count_related(Site, 'tenant'), virtualmachine_count=count_related(VirtualMachine, 'tenant'), vlan_count=count_related(VLAN, 'tenant'), - vrf_count=count_related(VRF, 'tenant') + vrf_count=count_related(VRF, 'tenant'), + cluster_count=count_related(Cluster, 'tenant') ) serializer_class = serializers.TenantSerializer filterset_class = filtersets.TenantFilterSet From 10ec31df3eb8b0c3b770b77468155b5817ad0d3a Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sat, 8 Jan 2022 00:13:58 -0600 Subject: [PATCH 040/104] Fix #8287 - Correct label in export template form --- docs/release-notes/version-3.1.md | 4 ++++ netbox/extras/forms/models.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 63e54fcea..7afee4603 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,6 +2,10 @@ ## v3.1.6 (FUTURE) +### Bug Fixes + +* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in Export Template Form + --- ## v3.1.5 (2022-01-06) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 89ab7aa19..4f50ba8f4 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -82,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): model = ExportTemplate fields = '__all__' fieldsets = ( - ('Custom Link', ('name', 'content_type', 'description')), + ('Export Template', ('name', 'content_type', 'description')), ('Template', ('template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) From f1472d218e157160306e7007fb3a705208901a71 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sat, 8 Jan 2022 00:21:38 -0600 Subject: [PATCH 041/104] Update changelog for #8262 and #8265 --- docs/release-notes/version-3.1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 7afee4603..64bb0c12e 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,6 +2,11 @@ ## v3.1.6 (FUTURE) +### Enhancements + +* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cables to tenant stats +* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add newer Stackwise-n interface types + ### Bug Fixes * [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in Export Template Form From 0f58faaddbfce5eef16e9ecc56bdf1528769e21c Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sat, 8 Jan 2022 12:25:30 -0600 Subject: [PATCH 042/104] #7853 - Initial work on Speed/Duplex. TODO: Documentation, Tests, Form order --- netbox/dcim/api/serializers.py | 3 ++- netbox/dcim/choices.py | 13 +++++++++++ netbox/dcim/filtersets.py | 2 ++ netbox/dcim/forms/bulk_create.py | 4 ++-- netbox/dcim/forms/bulk_edit.py | 11 ++++++--- netbox/dcim/forms/bulk_import.py | 6 ++++- netbox/dcim/forms/filtersets.py | 14 +++++++++-- netbox/dcim/forms/models.py | 7 +++--- .../migrations/0150_interface_speed_duplex.py | 23 +++++++++++++++++++ netbox/dcim/models/device_components.py | 12 ++++++++++ netbox/templates/dcim/interface.html | 8 +++++++ netbox/templates/dcim/interface_edit.html | 2 ++ 12 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 netbox/dcim/migrations/0150_interface_speed_duplex.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4d8638231..766373796 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -721,6 +721,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + duplex = ChoiceField(choices=InterfaceDuplexChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -746,7 +747,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', - 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', + 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 368ee1336..fa158c750 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -943,6 +943,19 @@ class InterfaceTypeChoices(ChoiceSet): ) +class InterfaceDuplexChoices(ChoiceSet): + + DUPLEX_HALF = 'half' + DUPLEX_FULL = 'full' + DUPLEX_AUTO = 'auto' + + CHOICES = ( + (DUPLEX_HALF, 'Half'), + (DUPLEX_FULL, 'Full'), + (DUPLEX_AUTO, 'Auto'), + ) + + class InterfaceModeChoices(ChoiceSet): MODE_ACCESS = 'access' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 104836120..8a83a8a6b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1196,6 +1196,8 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT queryset=Interface.objects.all(), label='LAG interface (ID)', ) + speed = MultiValueNumberFilter() + duplex = django_filters.CharFilter() mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() tag = TagFilter() diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 02c8feb4b..4d73fcc2a 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -72,12 +72,12 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 69fa6eb3a..3d73ada47 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,7 +11,7 @@ from ipam.models import ASN, VLAN, VRF from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, + DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, ) __all__ = ( @@ -1028,7 +1028,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', + 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ]), AddRemoveTagsForm, @@ -1064,6 +1064,11 @@ class InterfaceBulkEditForm( }, label='LAG' ) + speed = forms.IntegerField( + required=False, + widget=SelectSpeedWidget(attrs={'readonly': None}), + label='Speed' + ) mgmt_only = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, @@ -1089,7 +1094,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', + 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index fce98f7cb..ef8d79082 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -618,6 +618,10 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): choices=InterfaceTypeChoices, help_text='Physical medium' ) + duplex = CSVChoiceField( + choices=InterfaceDuplexChoices, + help_text='Duplex' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -638,7 +642,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index c231f56df..188a5f242 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -10,7 +10,7 @@ from ipam.models import ASN, VRF from tenancy.forms import TenancyFilterForm from utilities.forms import ( APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, - StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget, ) from wireless.choices import * @@ -920,7 +920,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface field_groups = [ ['q', 'tag'], - ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only'], + ['name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only'], ['vrf_id', 'mac_address', 'wwn'], ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], @@ -935,6 +935,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelectMultiple() ) + speed = forms.IntegerField( + required=False, + label='Select Speed', + widget=SelectSpeedWidget(attrs={'readonly': None}) + ) + duplex = forms.ChoiceField( + choices=InterfaceDuplexChoices, + required=False, + label='Select Duplex' + ) enabled = forms.NullBooleanField( required=False, widget=StaticSelect( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 801659574..07fa07e12 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -14,7 +14,7 @@ from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, - SlugField, StaticSelect, + SlugField, StaticSelect, SelectSpeedWidget, ) from virtualization.models import Cluster, ClusterGroup from wireless.models import WirelessLAN, WirelessLANGroup @@ -1274,12 +1274,12 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', + 'device', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] fieldsets = ( - ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), + ('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1295,6 +1295,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), + 'speed': SelectSpeedWidget(attrs={'readonly': None}), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/dcim/migrations/0150_interface_speed_duplex.py b/netbox/dcim/migrations/0150_interface_speed_duplex.py new file mode 100644 index 000000000..f9517107a --- /dev/null +++ b/netbox/dcim/migrations/0150_interface_speed_duplex.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.10 on 2022-01-08 18:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0149_interface_vrf'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='duplex', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='interface', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 916161ced..d876e7755 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -551,6 +551,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo verbose_name='Management only', help_text='This interface is used only for out-of-band management' ) + speed = models.PositiveIntegerField( + verbose_name='Speed', + blank=True, + null=True + ) + duplex = models.CharField( + verbose_name='Duplex', + max_length=50, + blank=True, + null=True, + choices=InterfaceDuplexChoices + ) wwn = WWNField( null=True, blank=True, diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bc9611992..bf81a33f2 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -46,6 +46,14 @@ Type {{ object.get_type_display }} + + Speed + {{ object.speed|humanize_speed|placeholder }} + + + Duplex + {{ object.get_duplex_display }} + Enabled {% checkmark object.enabled %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index f41e5ced6..e45cdd685 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -16,6 +16,8 @@ {% endif %} {% render_field form.name %} {% render_field form.type %} + {% render_field form.speed %} + {% render_field form.duplex %} {% render_field form.label %} {% render_field form.description %} {% render_field form.tags %} From f66a265fcf2eff66e05ceb6237add43a23ab3668 Mon Sep 17 00:00:00 2001 From: Jason Yates Date: Sat, 8 Jan 2022 21:55:07 +0000 Subject: [PATCH 043/104] Fixes #8246 - Circuits list view to display formatted commit rate Adds a custom column class to format the commit rate in the circuits table view using humanize_speed template helper. Export still exports the raw number. --- netbox/circuits/tables.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 86a55eba5..0b7ad203d 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -22,6 +22,25 @@ CIRCUITTERMINATION_LINK = """ {% endif %} """ +# +# Table columns +# + +class CommitRateColumn(tables.TemplateColumn): + """ + Humanize the commit rate in the column view + """ + + template_code = """ + {% load helpers %} + {{ record.commit_rate|humanize_speed }} + """ + + def __init__(self, *args, **kwargs): + super().__init__(template_code=self.template_code, *args, **kwargs) + + def value(self, value): + return str(value) if value else None # # Providers @@ -119,6 +138,7 @@ class CircuitTable(BaseTable): template_code=CIRCUITTERMINATION_LINK, verbose_name='Side Z' ) + commit_rate = CommitRateColumn() comments = MarkdownColumn() tags = TagColumn( url_name='circuits:circuit_list' From f7324934731c311012bddfe7a843d56841cb7dbe Mon Sep 17 00:00:00 2001 From: Jason Yates Date: Sat, 8 Jan 2022 22:24:25 +0000 Subject: [PATCH 044/104] Fixing code style E302 --- netbox/circuits/tables.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 0b7ad203d..29bf704f0 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -26,6 +26,7 @@ CIRCUITTERMINATION_LINK = """ # Table columns # + class CommitRateColumn(tables.TemplateColumn): """ Humanize the commit rate in the column view @@ -46,6 +47,7 @@ class CommitRateColumn(tables.TemplateColumn): # Providers # + class ProviderTable(BaseTable): pk = ToggleColumn() name = tables.Column( From e84a282aa607d56b354220a6ba01a2f0c4e344a8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 08:24:45 -0500 Subject: [PATCH 045/104] Revert REST API changes from #8284 --- docs/release-notes/version-3.1.md | 6 +++--- netbox/tenancy/api/serializers.py | 3 +-- netbox/tenancy/api/views.py | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 64bb0c12e..459e62d60 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -4,12 +4,12 @@ ### Enhancements -* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cables to tenant stats -* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add newer Stackwise-n interface types +* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats +* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types ### Bug Fixes -* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in Export Template Form +* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form --- diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 0312949d1..a0482aa1d 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -40,14 +40,13 @@ class TenantSerializer(PrimaryModelSerializer): vlan_count = serializers.IntegerField(read_only=True) vrf_count = serializers.IntegerField(read_only=True) cluster_count = serializers.IntegerField(read_only=True) - cable_count = serializers.IntegerField(read_only=True) class Meta: model = Tenant fields = [ 'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', - 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', 'cable_count', + 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index f4b8abbf1..7e3358e7f 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -47,8 +47,7 @@ class TenantViewSet(CustomFieldModelViewSet): site_count=count_related(Site, 'tenant'), virtualmachine_count=count_related(VirtualMachine, 'tenant'), vlan_count=count_related(VLAN, 'tenant'), - vrf_count=count_related(VRF, 'tenant'), - cable_count=count_related(Cable, 'tenant') + vrf_count=count_related(VRF, 'tenant') ) serializer_class = serializers.TenantSerializer filterset_class = filtersets.TenantFilterSet From 5aa7dedccb8640de64ac54ae7fc2608fcd095e16 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 08:38:08 -0500 Subject: [PATCH 046/104] Changelog for #8246, #8285 --- docs/release-notes/version-3.1.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 459e62d60..a39cff451 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -4,11 +4,13 @@ ### Enhancements +* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table * [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats * [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types ### Bug Fixes +* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer * [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form --- From 02519b270efe07f5efa3a05400f55b01b3cf39cb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 09:30:50 -0500 Subject: [PATCH 047/104] Fixes #8301: Fix delete button for various object children views --- docs/release-notes/version-3.1.md | 1 + netbox/templates/dcim/device/consoleports.html | 1 + netbox/templates/dcim/device/consoleserverports.html | 1 + netbox/templates/dcim/device/devicebays.html | 1 + netbox/templates/dcim/device/frontports.html | 1 + netbox/templates/dcim/device/interfaces.html | 1 + netbox/templates/dcim/device/inventory.html | 1 + netbox/templates/dcim/device/poweroutlets.html | 1 + netbox/templates/dcim/device/powerports.html | 1 + netbox/templates/dcim/device/rearports.html | 1 + netbox/templates/ipam/aggregate/prefixes.html | 1 + netbox/templates/ipam/iprange/ip_addresses.html | 1 + netbox/templates/ipam/prefix/ip_addresses.html | 1 + netbox/templates/ipam/prefix/ip_ranges.html | 1 + netbox/templates/ipam/prefix/prefixes.html | 1 + netbox/templates/ipam/vlan/interfaces.html | 1 + netbox/templates/ipam/vlan/vminterfaces.html | 1 + netbox/templates/virtualization/cluster/devices.html | 1 + netbox/templates/virtualization/cluster/virtual_machines.html | 1 + netbox/templates/virtualization/virtualmachine/interfaces.html | 1 + 20 files changed, 20 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index a39cff451..649bb8ce8 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -12,6 +12,7 @@ * [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer * [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form +* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views --- diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index 65c6651da..f96854ca8 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -45,5 +45,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 7c56eceac..eb27b4ab0 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -45,5 +45,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 5c46ce3dc..672cb192a 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -42,5 +42,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 814eed25a..816d193de 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -45,5 +45,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 7141191dc..d7f8dff55 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -80,5 +80,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 04c2ebea4..c6452cf78 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -42,5 +42,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index a4517c2e2..19d8298af 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -45,5 +45,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index f1ea82382..82c088392 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -45,5 +45,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index 4a4198c03..868def466 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -45,5 +45,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index 4d9b6d105..14d4b38bb 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -40,5 +40,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index 8663c158f..a13910406 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -38,5 +38,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index ae5d3cf74..b26375ebe 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -38,5 +38,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index 3d5e0c4c0..b262be821 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -38,5 +38,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 21756a36a..039b1ca3e 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -40,5 +40,6 @@ {% endblock %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/ipam/vlan/interfaces.html b/netbox/templates/ipam/vlan/interfaces.html index 3ce00631f..51df17edc 100644 --- a/netbox/templates/ipam/vlan/interfaces.html +++ b/netbox/templates/ipam/vlan/interfaces.html @@ -14,5 +14,6 @@ {% endblock content %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/ipam/vlan/vminterfaces.html b/netbox/templates/ipam/vlan/vminterfaces.html index fcd207894..f12e9df86 100644 --- a/netbox/templates/ipam/vlan/vminterfaces.html +++ b/netbox/templates/ipam/vlan/vminterfaces.html @@ -14,5 +14,6 @@ {% endblock content %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html index 700006196..075f34c7e 100644 --- a/netbox/templates/virtualization/cluster/devices.html +++ b/netbox/templates/virtualization/cluster/devices.html @@ -24,5 +24,6 @@ {% endblock content %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html index 5b0359e07..8b4191259 100644 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -29,5 +29,6 @@ {% endblock content %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index 5f6ab52ad..de657b3b3 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -40,5 +40,6 @@ {% endblock content %} {% block modals %} + {{ block.super }} {% table_config_form table %} {% endblock modals %} From 076ca46ab4e86af04f428d5aad7ecdd3ccab99ab Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 09:48:14 -0500 Subject: [PATCH 048/104] Closes #8302: Linkify role column in device & VM tables --- docs/release-notes/version-3.1.md | 1 + netbox/project-static/dist/netbox-dark.css | Bin 374488 -> 374545 bytes netbox/project-static/dist/netbox-light.css | Bin 232256 -> 232279 bytes netbox/project-static/dist/netbox-print.css | Bin 728058 -> 728187 bytes netbox/project-static/styles/netbox.scss | 4 ++++ netbox/utilities/tables.py | 18 +++++++++--------- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 649bb8ce8..c13a5df1f 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -7,6 +7,7 @@ * [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table * [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats * [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types +* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables ### Bug Fixes diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index a53e70f517562a97c4d2ca3a3bf80292e7319d42..9e85e4754a74f2e68fbb628bbf8e20e2cf0b7b54 100644 GIT binary patch delta 38 ucmccdR&3%sv4$4L7N!>F7M3lnXS=5hq%g5CDI`wUPh~RNex{q1M;ibnoefz4 delta 25 hcmbREPVB~8v4$4L7N!>F7M3lnXS=tv^{{el0|1YS37`M~ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 29c3ad3c7397d321c8c364d018fece11d689572e..49b8aae7335d645a1b166f744787b23e80476896 100644 GIT binary patch delta 42 xcmX>wjqmz2zJ?aY7N#xC26-ZhDe0*SiPg#ZIr&9anRywhMVTerb@P~oWdT@u4>AA% delta 30 mcmcaUjqkuTzJ?aY7N#xC26@aSdDYV&o?;T-9-GJfO9lYG0SpoV diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 23d0be3065b84454fd2ce4fa92748f37f69fdf06..a90c88398d1719c0b7ae2d020191fa4293ebbcd2 100644 GIT binary patch delta 73 zcmeyhUFY`>orV_17N!>F7M2#)7Pc1l7LFFqEnEikhQS(p?Or~kdiqP%_oK`!ek0Qw#pq5uE@ delta 40 wcmeypL+96aorV_17N!>F7M2#)7Pc1l7LFFqEnEi - {{ value }} - - {% else %} - — - {% endif %} - """ +{% load helpers %} + {% if value %} + + {{ value }} + +{% else %} + — +{% endif %} +""" def __init__(self, *args, **kwargs): super().__init__(template_code=self.template_code, *args, **kwargs) From aed23d61fc752bc6a913759995ff1d57707e1602 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 11:17:40 -0500 Subject: [PATCH 049/104] Replace ButtonsColumn with ActionsColumn --- netbox/dcim/tables/devices.py | 61 ++++++------------ netbox/dcim/tables/devicetypes.py | 67 +++++++++----------- netbox/dcim/tables/sites.py | 9 ++- netbox/dcim/tables/template_code.py | 6 +- netbox/ipam/tables/vlans.py | 11 ++-- netbox/utilities/tables/columns.py | 89 ++++++++------------------- netbox/utilities/tests/test_tables.py | 3 +- netbox/virtualization/tables.py | 9 ++- 8 files changed, 90 insertions(+), 165 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f21bc3204..1241143b7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -7,7 +7,7 @@ from dcim.models import ( ) from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import * @@ -322,10 +322,8 @@ class DeviceConsolePortTable(ConsolePortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsolePort, - buttons=('edit', 'delete'), - prepend_template=CONSOLEPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=CONSOLEPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -367,10 +365,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsoleServerPort, - buttons=('edit', 'delete'), - prepend_template=CONSOLESERVERPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=CONSOLESERVERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -412,10 +408,8 @@ class DevicePowerPortTable(PowerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerPort, - buttons=('edit', 'delete'), - prepend_template=POWERPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=POWERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -461,10 +455,8 @@ class DevicePowerOutletTable(PowerOutletTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerOutlet, - buttons=('edit', 'delete'), - prepend_template=POWEROUTLET_BUTTONS + actions = ActionsColumn( + extra_buttons=POWEROUTLET_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -551,10 +543,8 @@ class DeviceInterfaceTable(InterfaceTable): linkify=True, verbose_name='LAG' ) - actions = ButtonsColumn( - model=Interface, - buttons=('edit', 'delete'), - prepend_template=INTERFACE_BUTTONS + actions = ActionsColumn( + extra_buttons=INTERFACE_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -614,10 +604,8 @@ class DeviceFrontPortTable(FrontPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=FrontPort, - buttons=('edit', 'delete'), - prepend_template=FRONTPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=FRONTPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -662,10 +650,8 @@ class DeviceRearPortTable(RearPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=RearPort, - buttons=('edit', 'delete'), - prepend_template=REARPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=REARPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -713,10 +699,8 @@ class DeviceDeviceBayTable(DeviceBayTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=DEVICEBAY_BUTTONS + actions = ActionsColumn( + extra_buttons=DEVICEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -749,10 +733,8 @@ class ModuleBayTable(DeviceComponentTable): class DeviceModuleBayTable(ModuleBayTable): - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=MODULEBAY_BUTTONS + actions = ActionsColumn( + extra_buttons=MODULEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -803,10 +785,7 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItem diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 29fa4d4de..ecec67f7d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -6,8 +6,7 @@ from dcim.models import ( InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -113,10 +112,9 @@ class ComponentTemplateTable(BaseTable): class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsolePortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -126,10 +124,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsoleServerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -139,10 +136,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -152,10 +148,9 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerOutletTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -168,10 +163,9 @@ class InterfaceTemplateTable(ComponentTemplateTable): mgmt_only = BooleanColumn( verbose_name='Management Only' ) - actions = ButtonsColumn( - model=InterfaceTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -185,10 +179,9 @@ class FrontPortTemplateTable(ComponentTemplateTable): verbose_name='Position' ) color = ColorColumn() - actions = ButtonsColumn( - model=FrontPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -199,10 +192,9 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): color = ColorColumn() - actions = ButtonsColumn( - model=RearPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -212,9 +204,8 @@ class RearPortTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ModuleBayTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -224,9 +215,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=DeviceBayTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -236,9 +226,8 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class InventoryItemTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=InventoryItemTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) role = tables.Column( linkify=True diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 23ffabae2..98c5e3fd3 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -3,9 +3,9 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) -from .template_code import LOCATION_ELEVATIONS +from .template_code import LOCATION_BUTTONS __all__ = ( 'LocationTable', @@ -127,9 +127,8 @@ class LocationTable(BaseTable): tags = TagColumn( url_name='dcim:location_list' ) - actions = ButtonsColumn( - model=Location, - prepend_template=LOCATION_ELEVATIONS + actions = ActionsColumn( + extra_buttons=LOCATION_BUTTONS ) class Meta(BaseTable.Meta): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2b6c02b82..a1baeb336 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """ {{ value }} """ -LOCATION_ELEVATIONS = """ +LOCATION_BUTTONS = """ @@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ {% load helpers %} -{% if perms.dcim.add_invnetoryitemtemplate %} - +{% if perms.dcim.add_inventoryitemtemplate %} + {% endif %} diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 1379ad105..3454ddff4 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -5,8 +5,8 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, - TagColumn, TemplateColumn, ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, + TemplateColumn, ToggleColumn, ) from virtualization.models import VMInterface from ipam.models import * @@ -38,7 +38,7 @@ VLAN_PREFIXES = """ {% endfor %} """ -VLANGROUP_ADD_VLAN = """ +VLANGROUP_BUTTONS = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -77,9 +77,8 @@ class VLANGroupTable(BaseTable): tags = TagColumn( url_name='ipam:vlangroup_list' ) - actions = ButtonsColumn( - model=VLANGroup, - prepend_template=VLANGROUP_ADD_VLAN + actions = ActionsColumn( + extra_buttons=VLANGROUP_BUTTONS ) class Meta(BaseTable.Meta): diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index e601bd0cc..a319fc7ad 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -1,9 +1,10 @@ -from collections import namedtuple from dataclasses import dataclass from typing import Optional import django_tables2 as tables from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.template import Context, Template from django.urls import reverse from django.utils.safestring import mark_safe from django_tables2.utils import Accessor @@ -14,7 +15,6 @@ from utilities.utils import content_type_identifier, content_type_name __all__ = ( 'ActionsColumn', 'BooleanColumn', - 'ButtonsColumn', 'ChoiceFieldColumn', 'ColorColumn', 'ColoredLabelColumn', @@ -100,7 +100,14 @@ class ActionsItem: class ActionsColumn(tables.Column): - attrs = {'td': {'class': 'text-end noprint'}} + """ + A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include + additional buttons rendered from a template string. + + :param sequence: The ordered list of dropdown menu items to include + :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + """ + attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () actions = { 'edit': ActionsItem('Edit', 'pencil', 'change'), @@ -108,12 +115,10 @@ class ActionsColumn(tables.Column): 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, extra_actions=None, sequence=('edit', 'delete', 'changelog'), **kwargs): + def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): super().__init__(*args, **kwargs) - # Add/update any extra actions passed - if extra_actions: - self.actions.update(extra_actions) + self.extra_buttons = extra_buttons # Determine which actions to enable self.actions = { @@ -134,9 +139,10 @@ class ActionsColumn(tables.Column): url_appendix = f'?return_url={request.path}' if request else '' links = [] + user = getattr(request, 'user', AnonymousUser()) for action, attrs in self.actions.items(): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' - if attrs.permission is None or request.user.has_perm(permission): + if attrs.permission is None or user.has_perm(permission): url = reverse(f'{viewname_base}_{action}', kwargs={'pk': record.pk}) links.append(f'
  • ' f' {attrs.title}
  • ') @@ -144,68 +150,21 @@ class ActionsColumn(tables.Column): if not links: return '' - menu = f'' + f'' + + # Render any extra buttons from template code + if self.extra_buttons: + template = Template(self.extra_buttons) + context = getattr(table, "context", Context()) + context.update({'record': record}) + menu = template.render(context) + menu return mark_safe(menu) -class ButtonsColumn(tables.TemplateColumn): - """ - Render edit, delete, and changelog buttons for an object. - - :param model: Model class to use for calculating URL view names - :param prepend_content: Additional template content to render in the column (optional) - """ - buttons = ('changelog', 'edit', 'delete') - attrs = {'td': {'class': 'text-end text-nowrap noprint'}} - # Note that braces are escaped to allow for string formatting prior to template rendering - template_code = """ - {{% if "changelog" in buttons %}} - - - - {{% endif %}} - {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - - - - {{% endif %}} - {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} - - - - {{% endif %}} - """ - - def __init__(self, model, *args, buttons=None, prepend_template=None, **kwargs): - if prepend_template: - prepend_template = prepend_template.replace('{', '{{') - prepend_template = prepend_template.replace('}', '}}') - self.template_code = prepend_template + self.template_code - - template_code = self.template_code.format( - app_label=model._meta.app_label, - model_name=model._meta.model_name, - buttons=buttons - ) - - super().__init__(template_code=template_code, *args, **kwargs) - - # Exclude from export by default - if 'exclude_from_export' not in kwargs: - self.exclude_from_export = True - - self.extra_context.update({ - 'buttons': buttons or self.buttons, - }) - - def header(self): - return '' - - class ChoiceFieldColumn(tables.Column): """ Render a ChoiceField value inside a indicating a particular CSS class. This is useful for displaying colored diff --git a/netbox/utilities/tests/test_tables.py b/netbox/utilities/tests/test_tables.py index 119587ff8..55a5e4cc7 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/utilities/tests/test_tables.py @@ -30,7 +30,8 @@ class TagColumnTest(TestCase): def test_tagcolumn(self): template = Template('{% load render_table from django_tables2 %}{% render_table table %}') + table = TagColumnTable(Site.objects.all(), orderable=False) context = Context({ - 'table': TagColumnTable(Site.objects.all(), orderable=False) + 'table': table }) template.render(context) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 65f9f1257..0588f51a5 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -3,7 +3,7 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -183,10 +183,9 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): bridge = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=VMInterface, - buttons=('edit', 'delete'), - prepend_template=VMINTERFACE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=VMINTERFACE_BUTTONS ) class Meta(BaseTable.Meta): From 94c116617a67ac7b8c72b877f7506c367f05b0f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 11:20:06 -0500 Subject: [PATCH 050/104] Changelog for #7679 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6240016cf..d85ad17e2 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -57,6 +57,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation +* [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts From 72e17914e20845161ac41791c4af2b88d63dbe8e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 12:11:37 -0500 Subject: [PATCH 051/104] Closes #8296: Allow disabling custom links --- docs/models/extras/customlink.md | 2 +- docs/release-notes/version-3.2.md | 3 +++ netbox/extras/api/serializers.py | 2 +- netbox/extras/filtersets.py | 4 +++- netbox/extras/forms/bulk_edit.py | 4 ++++ netbox/extras/forms/bulk_import.py | 3 ++- netbox/extras/forms/filtersets.py | 12 +++++++++--- netbox/extras/forms/models.py | 2 +- .../migrations/0070_customlink_enabled.py | 18 ++++++++++++++++++ netbox/extras/models/models.py | 3 +++ netbox/extras/tables.py | 5 +++-- netbox/extras/templatetags/custom_links.py | 2 +- netbox/extras/tests/test_api.py | 7 +++++++ netbox/extras/tests/test_filtersets.py | 9 +++++++++ netbox/extras/tests/test_views.py | 16 +++++++++------- netbox/templates/extras/customlink.html | 4 ++++ netbox/utilities/tables/tables.py | 19 ++++++++++--------- 17 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 netbox/extras/migrations/0070_customlink_enabled.py diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 7fd510841..96ff0bbf7 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as: View NMS ``` -Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually. !!! warning Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e0ef639fa..31025bb85 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -64,6 +64,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group +* [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links ### Other Changes @@ -106,6 +107,8 @@ Inventory item templates can be arranged hierarchically within a device type, an * Add `cluster_types` field * extras.CustomField * Added `object_type` field +* extras.CustomLink + * Added `enabled` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint * Added the `min_vid` and `max_vid` fields diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index fa0e5189f..6279ea2b7 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -101,7 +101,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): class Meta: model = CustomLink fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', + 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bf25ff76c..a839e2dd3 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet): class Meta: model = CustomLink - fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + fields = [ + 'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 1b87256a5..56b51c894 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm): limit_choices_to=FeatureQuery('custom_fields'), required=False ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) new_window = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 9f44494e0..fa6d8af55 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm): class Meta: model = CustomLink fields = ( - 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', + 'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', + 'link_url', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 388cd1e60..330bb91e3 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm): field_groups = [ ['q'], - ['content_type', 'weight', 'new_window'], + ['content_type', 'enabled', 'new_window', 'weight'], ] content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False ) - weight = forms.IntegerField( - required=False + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) new_window = forms.NullBooleanField( required=False, @@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + weight = forms.IntegerField( + required=False + ) class ExportTemplateFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 55e58a7f2..ca2c6b900 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -53,7 +53,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): model = CustomLink fields = '__all__' fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')), + ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Templates', ('link_text', 'link_url')), ) widgets = { diff --git a/netbox/extras/migrations/0070_customlink_enabled.py b/netbox/extras/migrations/0070_customlink_enabled.py new file mode 100644 index 000000000..839a4dba5 --- /dev/null +++ b/netbox/extras/migrations/0070_customlink_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-10 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0069_custom_object_field'), + ] + + operations = [ + migrations.AddField( + model_name='customlink', + name='enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ac3a23410..3612b2a6f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -192,6 +192,9 @@ class CustomLink(ChangeLoggedModel): max_length=100, unique=True ) + enabled = models.BooleanField( + default=True + ) link_text = models.CharField( max_length=500, help_text="Jinja2 template code for link text" diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 071caa354..adfccb575 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -73,15 +73,16 @@ class CustomLinkTable(BaseTable): linkify=True ) content_type = ContentTypeColumn() + enabled = BooleanColumn() new_window = BooleanColumn() class Meta(BaseTable.Meta): model = CustomLink fields = ( - 'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', + 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', ) - default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window') + default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') # diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 32ec966b3..dd5467338 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -36,7 +36,7 @@ def custom_links(context, obj): Render all applicable links for the given object. """ content_type = ContentType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(content_type=content_type) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index d15b57e43..d790eff71 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): { 'content_type': 'dcim.site', 'name': 'Custom Link 4', + 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 5', + 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 6', + 'enabled': False, 'link_text': 'Link 6', 'link_url': 'http://example.com/?6', }, ] bulk_update_data = { 'new_window': True, + 'enabled': False, } @classmethod @@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): CustomLink( content_type=site_ct, name='Custom Link 1', + enabled=True, link_text='Link 1', link_url='http://example.com/?1', ), CustomLink( content_type=site_ct, name='Custom Link 2', + enabled=True, link_text='Link 2', link_url='http://example.com/?2', ), CustomLink( content_type=site_ct, name='Custom Link 3', + enabled=False, link_text='Link 3', link_url='http://example.com/?3', ), diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index a5f77afa9..3a08055cb 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 1', content_type=content_types[0], + enabled=True, weight=100, new_window=False, link_text='Link 1', @@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 2', content_type=content_types[1], + enabled=True, weight=200, new_window=False, link_text='Link 1', @@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 3', content_type=content_types[2], + enabled=False, weight=300, new_window=True, link_text='Link 1', @@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): params = {'weight': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_new_window(self): params = {'new_window': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 34d5cb67e..ea3a952d6 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): site_ct = ContentType.objects.get_for_model(Site) CustomLink.objects.bulk_create(( - CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'), - CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'), - CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'), + CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'), + CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'), + CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'), )) cls.form_data = { 'name': 'Custom Link X', 'content_type': site_ct.pk, + 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, 'link_text': 'Link X', @@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_type,weight,button_class,link_text,link_url", - "Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4", - "Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5", - "Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6", + "name,content_type,enabled,weight,button_class,link_text,link_url", + "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", + "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", + "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", ) cls.bulk_edit_data = { 'button_class': CustomLinkButtonClassChoices.CYAN, + 'enabled': False, 'weight': 200, } diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index ebf50882c..1f3866182 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -19,6 +19,10 @@ Content Type {{ object.content_type }} + + Enabled + {% checkmark object.enabled %} + Group Name {{ object.group_name|placeholder }} diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py index 6c3b56959..d1915569e 100644 --- a/netbox/utilities/tables/tables.py +++ b/netbox/utilities/tables/tables.py @@ -35,15 +35,16 @@ class BaseTable(tables.Table): if extra_columns is None: extra_columns = [] - # Add custom field columns - obj_type = ContentType.objects.get_for_model(self._meta.model) - cf_columns = [ - (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) - ] - cl_columns = [ - (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) - ] - extra_columns.extend([*cf_columns, *cl_columns]) + # Add custom field & custom link columns + content_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = CustomField.objects.filter(content_types=content_type) + extra_columns.extend([ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields + ]) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + extra_columns.extend([ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links + ]) super().__init__(*args, extra_columns=extra_columns, **kwargs) From 21e0e6e4959e1f0b791be342b16cf1e2aa48f693 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 14:03:07 -0500 Subject: [PATCH 052/104] Closes #6954: Remember users' table ordering preferences --- docs/development/user-preferences.md | 13 +++++----- docs/release-notes/version-3.2.md | 1 + netbox/circuits/views.py | 8 +++--- netbox/dcim/views.py | 16 ++++++------ netbox/extras/views.py | 8 +++--- netbox/ipam/views.py | 12 ++++----- netbox/netbox/views/generic/object_views.py | 7 +++--- netbox/tenancy/views.py | 10 ++++---- netbox/users/tests/test_preferences.py | 27 ++++++++++++++++++++- netbox/utilities/tables/__init__.py | 19 +++++++++++---- netbox/virtualization/views.py | 6 ++--- netbox/wireless/views.py | 6 ++--- 12 files changed, 84 insertions(+), 49 deletions(-) diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index a707eb6ad..622fbb4b9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,9 +4,10 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -|-------------------------|-------------| -| data_format | Preferred format when rendering raw data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.${table}.columns | The ordered list of columns to display when viewing the table | -| ui.colormode | Light or dark mode in the user interface | +| Name | Description | +|--------------------------|---------------------------------------------------------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| tables.${table}.ordering | A list of column names by which the table should be ordered | +| ui.colormode | Light or dark mode in the user interface | diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 31025bb85..a2bc5988e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -56,6 +56,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements +* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2f1addab1..97e985dcd 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404, redirect, render from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .choices import CircuitTerminationSideChoices @@ -35,7 +35,7 @@ class ProviderView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -96,7 +96,7 @@ class ProviderNetworkView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -150,7 +150,7 @@ class CircuitTypeView(generic.ObjectView): def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) circuits_table = tables.CircuitTable(circuits, exclude=('type',)) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e64124539..a85fc7438 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine @@ -165,7 +165,7 @@ class RegionView(generic.ObjectView): region=instance ) sites_table = tables.SiteTable(sites, exclude=('region',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_regions_table': child_regions_table, @@ -250,7 +250,7 @@ class SiteGroupView(generic.ObjectView): group=instance ) sites_table = tables.SiteTable(sites, exclude=('group',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_groups_table': child_groups_table, @@ -422,7 +422,7 @@ class LocationView(generic.ObjectView): cumulative=True ).filter(pk__in=location_ids).exclude(pk=instance.pk) child_locations_table = tables.LocationTable(child_locations) - paginate_table(child_locations_table, request) + configure_table(child_locations_table, request) return { 'rack_count': rack_count, @@ -493,7 +493,7 @@ class RackRoleView(generic.ObjectView): ) racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) - paginate_table(racks_table, request) + configure_table(racks_table, request) return { 'racks_table': racks_table, @@ -743,7 +743,7 @@ class ManufacturerView(generic.ObjectView): ) devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',)) - paginate_table(devicetypes_table, request) + configure_table(devicetypes_table, request) return { 'devicetypes_table': devicetypes_table, @@ -1439,7 +1439,7 @@ class DeviceRoleView(generic.ObjectView): device_role=instance ) devices_table = tables.DeviceTable(devices, exclude=('device_role',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, @@ -1503,7 +1503,7 @@ class PlatformView(generic.ObjectView): platform=instance ) devices_table = tables.DeviceTable(devices, exclude=('platform',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0df4d6905..59f922d82 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables @@ -215,7 +215,7 @@ class TagView(generic.ObjectView): data=tagged_items, orderable=False ) - paginate_table(taggeditem_table, request) + configure_table(taggeditem_table, request) object_types = [ { @@ -451,7 +451,7 @@ class ObjectChangeLogView(View): data=objectchanges, orderable=False ) - paginate_table(objectchanges_table, request) + configure_table(objectchanges_table, request) # Default to using "/.html" as the template, if it exists. Otherwise, # fall back to using base.html. @@ -571,7 +571,7 @@ class ObjectJournalView(View): assigned_object_id=obj.pk ) journalentry_table = tables.ObjectJournalTable(journalentries) - paginate_table(journalentry_table, request) + configure_table(journalentry_table, request) if request.user.has_perm('extras.add_journalentry'): form = forms.JournalEntryForm( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1f20e886f..23d6eb2a7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from dcim.tables import SiteTable from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface @@ -161,7 +161,7 @@ class RIRView(generic.ObjectView): rir=instance ) aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) - paginate_table(aggregates_table, request) + configure_table(aggregates_table, request) return { 'aggregates_table': aggregates_table, @@ -219,7 +219,7 @@ class ASNView(generic.ObjectView): def get_extra_context(self, request, instance): sites = instance.sites.restrict(request.user, 'view') sites_table = SiteTable(sites) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'sites_table': sites_table, @@ -356,7 +356,7 @@ class RoleView(generic.ObjectView): ) prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) - paginate_table(prefixes_table, request) + configure_table(prefixes_table, request) return { 'prefixes_table': prefixes_table, @@ -664,7 +664,7 @@ class IPAddressView(generic.ObjectView): vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) ) related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - paginate_table(related_ips_table, request) + configure_table(related_ips_table, request) return { 'parent_prefixes_table': parent_prefixes_table, @@ -800,7 +800,7 @@ class VLANGroupView(generic.ObjectView): vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes')) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') - paginate_table(vlans_table, request) + configure_table(vlans_table, request) # Compile permissions list for rendering the object table permissions = { diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index d8850391b..f5e315801 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -15,7 +15,6 @@ from django.utils.safestring import mark_safe from django.views.generic import View from django_tables2.export import TableExport -from dcim.forms.object_create import ComponentCreateForm from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror @@ -23,7 +22,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin @@ -135,7 +134,7 @@ class ObjectChildrenView(ObjectView): # Determine whether to display bulk action checkboxes if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') - paginate_table(table, request) + configure_table(table, request) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): @@ -284,7 +283,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): # Render the objects table table = self.get_table(request, permissions) - paginate_table(table, request) + configure_table(table, request) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b41af62ee..d634292ec 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -6,7 +6,7 @@ from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables @@ -38,7 +38,7 @@ class TenantGroupView(generic.ObjectView): group=instance ) tenants_table = tables.TenantTable(tenants, exclude=('group',)) - paginate_table(tenants_table, request) + configure_table(tenants_table, request) return { 'tenants_table': tenants_table, @@ -184,7 +184,7 @@ class ContactGroupView(generic.ObjectView): group=instance ) contacts_table = tables.ContactTable(contacts, exclude=('group',)) - paginate_table(contacts_table, request) + configure_table(contacts_table, request) return { 'child_groups_table': child_groups_table, @@ -251,7 +251,7 @@ class ContactRoleView(generic.ObjectView): ) contacts_table = tables.ContactAssignmentTable(contact_assignments) contacts_table.columns.hide('role') - paginate_table(contacts_table, request) + configure_table(contacts_table, request) return { 'contacts_table': contacts_table, @@ -308,7 +308,7 @@ class ContactView(generic.ObjectView): ) assignments_table = tables.ContactAssignmentTable(contact_assignments) assignments_table.columns.hide('contact') - paginate_table(assignments_table, request) + configure_table(assignments_table, request) return { 'assignments_table': assignments_table, diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index 23e94e8ef..035ca6840 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -1,7 +1,13 @@ from django.contrib.auth.models import User -from django.test import override_settings, TestCase +from django.test import override_settings +from django.test.client import RequestFactory +from django.urls import reverse +from dcim.models import Site +from dcim.tables import SiteTable from users.preferences import UserPreference +from utilities.tables import configure_table +from utilities.testing import TestCase DEFAULT_USER_PREFERENCES = { @@ -12,6 +18,7 @@ DEFAULT_USER_PREFERENCES = { class UserPreferencesTest(TestCase): + user_permissions = ['dcim.view_site'] def test_userpreference(self): CHOICES = ( @@ -37,3 +44,21 @@ class UserPreferencesTest(TestCase): userconfig = user.config self.assertEqual(userconfig.data, DEFAULT_USER_PREFERENCES) + + def test_table_ordering(self): + url = reverse('dcim:site_list') + response = self.client.get(f"{url}?sort=status") + self.assertEqual(response.status_code, 200) + + # Check that table ordering preference has been recorded + self.user.refresh_from_db() + ordering = self.user.config.get(f'tables.SiteTable.ordering') + self.assertEqual(ordering, ['status']) + + # Check that a recorded preference is honored by default + self.user.config.set(f'tables.SiteTable.ordering', ['-status'], commit=True) + table = SiteTable(Site.objects.all()) + request = RequestFactory().get(url) + request.user = self.user + configure_table(table, request) + self.assertEqual(table.order_by, ('-status',)) diff --git a/netbox/utilities/tables/__init__.py b/netbox/utilities/tables/__init__.py index 37dd75144..25fa95296 100644 --- a/netbox/utilities/tables/__init__.py +++ b/netbox/utilities/tables/__init__.py @@ -5,14 +5,23 @@ from .columns import * from .tables import * -# -# Pagination -# - -def paginate_table(table, request): +def configure_table(table, request): """ Paginate a table given a request context. """ + # Save ordering preference + if request.user.is_authenticated: + table_name = table.__class__.__name__ + if table.prefixed_order_by_field in request.GET: + # If an ordering has been specified as a query parameter, save it as the + # user's preferred ordering for this table. + ordering = request.GET.getlist(table.prefixed_order_by_field) + request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): + # If no ordering has been specified, set the preferred ordering (if any). + table.order_by = ordering + + # Paginate the table results paginate = { 'paginator_class': EnhancedPaginator, 'per_page': get_paginate_count(request) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 742d6d9ea..0fc8c9bf7 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,7 +11,7 @@ from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -41,7 +41,7 @@ class ClusterTypeView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('type',)) - paginate_table(clusters_table, request) + configure_table(clusters_table, request) return { 'clusters_table': clusters_table, @@ -103,7 +103,7 @@ class ClusterGroupView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('group',)) - paginate_table(clusters_table, request) + configure_table(clusters_table, request) return { 'clusters_table': clusters_table, diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index dd1e760bb..443cf8eef 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,6 +1,6 @@ from dcim.models import Interface from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import * @@ -31,7 +31,7 @@ class WirelessLANGroupView(generic.ObjectView): group=instance ) wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) - paginate_table(wirelesslans_table, request) + configure_table(wirelesslans_table, request) return { 'wirelesslans_table': wirelesslans_table, @@ -99,7 +99,7 @@ class WirelessLANView(generic.ObjectView): wireless_lans=instance ) interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) - paginate_table(interfaces_table, request) + configure_table(interfaces_table, request) return { 'interfaces_table': interfaces_table, From ff396b595370a3901eb9c605756c7328ba2785fe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 14:27:52 -0500 Subject: [PATCH 053/104] Fix CSV import test & form cleanup --- netbox/dcim/forms/bulk_import.py | 1 + netbox/dcim/forms/models.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index ef8d79082..acce43be0 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -620,6 +620,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): ) duplex = CSVChoiceField( choices=InterfaceDuplexChoices, + required=False, help_text='Duplex' ) mode = CSVChoiceField( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 07fa07e12..378a567fc 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1292,10 +1292,11 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), + 'speed': SelectSpeedWidget(), + 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), - 'speed': SelectSpeedWidget(attrs={'readonly': None}), } labels = { 'mode': '802.1Q Mode', From 9152ba72f1ae2a4602a83558d1b8a77452f8cb59 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 14:44:25 -0500 Subject: [PATCH 054/104] Fixes #8306: Redirect user to previous page after login --- docs/release-notes/version-3.1.md | 1 + netbox/templates/inc/profile_button.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index c13a5df1f..0b3945119 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -14,6 +14,7 @@ * [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer * [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form * [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views +* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login --- diff --git a/netbox/templates/inc/profile_button.html b/netbox/templates/inc/profile_button.html index 230aa02ad..1e562651f 100644 --- a/netbox/templates/inc/profile_button.html +++ b/netbox/templates/inc/profile_button.html @@ -38,7 +38,7 @@ {% else %}
    - + Log In + + + +
    + +
    +
    + {% render_field form.device %} +
    +
    + {% render_field form.virtual_machine %} +
    +
    + + {# Template or custom #} +
    +
    + +
    +
    +
    +
    + {% render_field form.service_template %} +
    +
    + {% render_field form.name %} + {% render_field form.protocol %} + {% render_field form.ports %} +
    +
    + {% render_field form.ipaddresses %} + {% render_field form.description %} + {% render_field form.tags %} + + + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} + {% endif %} +{% endblock %} From 5b851a2d094cf2fe85fa94750900fee3b0254cf0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 10:48:08 -0500 Subject: [PATCH 066/104] Changelog for #1591 --- docs/release-notes/version-3.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index a2bc5988e..b82dce917 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,10 @@ ### New Features +#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) + +A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. + #### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. @@ -83,6 +87,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` * `/api/dcim/module-types/` + * `/api/extras/service-templates/` * circuits.ProviderNetwork * Added `service_id` field * dcim.ConsolePort From 707aad234eaca214557832fa29652fd90c635a39 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 11:27:29 -0500 Subject: [PATCH 067/104] Add view test for creating service from template --- netbox/ipam/tests/test_views.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 928a8b1c8..16439f453 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -719,3 +719,29 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'ports': '106,107', 'description': 'New description', } + + def test_create_from_template(self): + self.add_permissions('ipam.add_service') + + device = Device.objects.first() + service_template = ServiceTemplate.objects.create( + name='HTTP', + protocol=ServiceProtocolChoices.PROTOCOL_TCP, + ports=[80], + description='Hypertext transfer protocol' + ) + + request = { + 'path': self._get_url('add'), + 'data': { + 'device': device.pk, + 'service_template': service_template.pk, + }, + } + self.assertHttpStatus(self.client.post(**request), 302) + instance = self._get_queryset().order_by('pk').last() + self.assertEqual(instance.device, device) + self.assertEqual(instance.name, service_template.name) + self.assertEqual(instance.protocol, service_template.protocol) + self.assertEqual(instance.ports, service_template.ports) + self.assertEqual(instance.description, service_template.description) From b21b6238cf7392b20c348474f4e4d84510cf5651 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 11:52:06 -0500 Subject: [PATCH 068/104] Fix test permissions --- netbox/ipam/tests/test_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 16439f453..672cfbe08 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -720,6 +720,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_from_template(self): self.add_permissions('ipam.add_service') From 7767692394c25fb050193d67afa8546caffcd1e7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 12:10:25 -0500 Subject: [PATCH 069/104] Changelog for #8295 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b82dce917..dec5843dc 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -69,6 +69,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group +* [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links ### Other Changes From 3e3880823b6f2fb528cd64c00acb863f17e96bae Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 17 Jan 2022 11:12:54 -0500 Subject: [PATCH 070/104] Merge v3.1.6 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 4 -- docs/plugins/development.md | 3 + docs/release-notes/version-3.1.md | 12 +++- netbox/circuits/api/serializers.py | 2 +- netbox/circuits/tables.py | 12 ++-- netbox/dcim/api/serializers.py | 14 +++-- netbox/dcim/forms/filtersets.py | 2 +- netbox/dcim/svg.py | 7 ++- netbox/dcim/tables/cables.py | 2 +- netbox/dcim/tables/devices.py | 31 ++++++---- netbox/dcim/tables/devicetypes.py | 4 +- netbox/dcim/tables/power.py | 4 +- netbox/dcim/tables/racks.py | 12 ++-- netbox/dcim/tables/sites.py | 12 ++-- netbox/extras/api/serializers.py | 13 +++-- netbox/extras/forms/customfields.py | 63 ++++++++++----------- netbox/extras/tables.py | 12 ++-- netbox/ipam/models/ip.py | 21 ++++++- netbox/ipam/tables/fhrp.py | 2 +- netbox/ipam/tables/ip.py | 27 ++++++--- netbox/ipam/tables/services.py | 5 +- netbox/ipam/tables/vlans.py | 7 ++- netbox/ipam/tables/vrfs.py | 5 +- netbox/netbox/views/generic/bulk_views.py | 2 +- netbox/templates/inc/filter_list.html | 6 +- netbox/templates/ipam/asn.html | 2 +- netbox/tenancy/tables.py | 17 ++++-- netbox/utilities/forms/forms.py | 14 ++--- netbox/virtualization/tables.py | 19 +++++-- netbox/wireless/tables.py | 8 ++- requirements.txt | 6 +- 33 files changed, 225 insertions(+), 129 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 594f23f9a..16182af64 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.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.5 + placeholder: v3.1.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index b1193ae02..0be999b16 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.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.5 + placeholder: v3.1.6 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index cbc893aa9..aaa9c7f44 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -98,10 +98,6 @@ psycopg2-binary # https://github.com/yaml/pyyaml PyYAML -# In-memory key/value store used for caching and queuing -# https://github.com/andymccurdy/redis-py -redis - # Social authentication framework # https://github.com/python-social-auth/social-core social-auth-core[all] diff --git a/docs/plugins/development.md b/docs/plugins/development.md index d20f73cb6..d488cad6b 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -1,5 +1,8 @@ # Plugin Development +!!! info "Help Improve the NetBox Plugins Framework!" + We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338). + This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. Plugins can do a lot, including: diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 898d77437..c42837b24 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,16 +1,23 @@ # NetBox v3.1 -## v3.1.6 (FUTURE) +## v3.1.7 (FUTURE) + +--- + +## v3.1.6 (2022-01-17) ### Enhancements * [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table * [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats * [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types +* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation * [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables +* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times ### Bug Fixes +* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations * [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer * [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form * [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views @@ -19,6 +26,9 @@ * [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously * [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values * [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter +* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers +* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form +* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms --- diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 7a827d547..90767d081 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - '_occupied', + '_occupied', 'created', 'last_updated', ] diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 69fe3cf1f..b5fdc5440 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -66,7 +66,7 @@ class ProviderTable(BaseTable): model = Provider fields = ( 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', - 'comments', 'tags', + 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') @@ -90,7 +90,9 @@ class ProviderNetworkTable(BaseTable): class Meta(BaseTable.Meta): model = ProviderNetwork - fields = ('pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'tags') + fields = ( + 'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags', + ) default_columns = ('pk', 'name', 'provider', 'service_id', 'description') @@ -112,7 +114,9 @@ class CircuitTypeTable(BaseTable): class Meta(BaseTable.Meta): model = CircuitType - fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') @@ -149,7 +153,7 @@ class CircuitTable(BaseTable): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'tags', + 'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4d8638231..527c1e948 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -221,7 +221,7 @@ class RackReservationSerializer(PrimaryModelSerializer): class Meta: model = RackReservation fields = [ - 'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', + 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags', 'custom_fields', ] @@ -913,7 +913,7 @@ class CableSerializer(PrimaryModelSerializer): fields = [ 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', 'custom_fields', + 'tags', 'custom_fields', 'created', 'last_updated', ] def _get_termination(self, obj, side): @@ -1007,7 +1007,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer): class Meta: model = VirtualChassis - fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] + fields = [ + 'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count', + 'created', 'last_updated', + ] # @@ -1026,7 +1029,10 @@ class PowerPanelSerializer(PrimaryModelSerializer): class Meta: model = PowerPanel - fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] + fields = [ + 'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count', + 'created', 'last_updated', + ] class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index f84b42aa1..6c192f462 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -157,7 +157,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Location field_groups = [ - ['q'], + ['q', 'tag'], ['region_id', 'site_group_id', 'site_id', 'parent_id'], ['tenant_group_id', 'tenant_id'], ] diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index e19e8fa2f..1058d8385 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -19,7 +19,12 @@ __all__ = ( def get_device_name(device): - return device.name or str(device.device_type) + if device.virtual_chassis: + return f'{device.virtual_chassis.name}:{device.vc_position}' + elif device.name: + return device.name + else: + return str(device.device_type) class RackElevationSVG: diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 9b912894b..bea2c0adf 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -56,7 +56,7 @@ class CableTable(BaseTable): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', 'tenant', 'color', 'length', 'tags', + 'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7bfa09c21..f5ca49187 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -99,7 +99,7 @@ class DeviceRoleTable(BaseTable): model = DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') @@ -131,7 +131,7 @@ class PlatformTable(BaseTable): model = Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'tags', 'actions', + 'description', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', @@ -205,7 +205,8 @@ class DeviceTable(BaseTable): fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', + 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -311,7 +312,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): model = ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -353,7 +354,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = ConsoleServerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -396,7 +397,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', - 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -444,7 +446,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', - 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -524,6 +527,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -594,6 +598,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -641,7 +646,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -691,7 +696,11 @@ class DeviceBayTable(DeviceComponentTable): class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags', + 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description') @@ -774,7 +783,7 @@ class InventoryItemTable(DeviceComponentTable): model = InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', - 'asset_tag', 'description', 'discovered', 'tags', + 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', @@ -847,5 +856,5 @@ class VirtualChassisTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualChassis - fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags') + fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index ecec67f7d..93832d706 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -53,7 +53,7 @@ class ManufacturerTable(BaseTable): model = Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', @@ -87,7 +87,7 @@ class DeviceTypeTable(BaseTable): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'comments', 'instance_count', 'tags', + 'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ac58b64de..c1ea8a34c 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPanel - fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags') + fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -72,7 +72,7 @@ class PowerFeedTable(CableTerminationTable): fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', - 'comments', 'tags', + 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 565966a39..55c6f9ba8 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -30,7 +30,10 @@ class RackRoleTable(BaseTable): class Meta(BaseTable.Meta): model = RackRole - fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'rack_count', 'color', 'description') @@ -86,8 +89,9 @@ class RackTable(BaseTable): class Meta(BaseTable.Meta): model = Rack fields = ( - 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', - 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', + 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', + 'get_power_utilization', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', @@ -125,6 +129,6 @@ class RackReservationTable(BaseTable): model = RackReservation fields = ( 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 98c5e3fd3..32bf000ef 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -35,7 +35,9 @@ class RegionTable(BaseTable): class Meta(BaseTable.Meta): model = Region - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -59,7 +61,9 @@ class SiteGroupTable(BaseTable): class Meta(BaseTable.Meta): model = SiteGroup - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -96,7 +100,7 @@ class SiteTable(BaseTable): fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', - 'actions', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') @@ -135,6 +139,6 @@ class LocationTable(BaseTable): model = Location fields = ( 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6279ea2b7..79fab4a90 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -63,7 +63,7 @@ class WebhookSerializer(ValidatedModelSerializer): fields = [ 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - 'conditions', 'ssl_verification', 'ca_file_path', + 'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated', ] @@ -84,7 +84,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): model = CustomField fields = [ 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', - 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', + 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', + 'last_updated', ] @@ -102,7 +103,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): model = CustomLink fields = [ 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', - 'button_class', 'new_window', + 'button_class', 'new_window', 'created', 'last_updated', ] @@ -120,7 +121,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer): model = ExportTemplate fields = [ 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', + 'file_extension', 'as_attachment', 'created', 'last_updated', ] @@ -134,7 +135,9 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag - fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated', + ] # diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 0a2299945..8912d0365 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -4,7 +4,7 @@ from django.db.models import Q from extras.choices import * from extras.models import * -from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm +from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm __all__ = ( 'CustomFieldModelCSVForm', @@ -34,6 +34,9 @@ class CustomFieldsMixin: raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") return ContentType.objects.get_for_model(self.model) + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type) + def _get_form_field(self, customfield): return customfield.to_form_field() @@ -41,10 +44,7 @@ class CustomFieldsMixin: """ Append form fields for all CustomFields assigned to this object type. """ - content_type = self._get_content_type() - - # Append form fields; assign initial values if modifying and existing object - for customfield in CustomField.objects.filter(content_types=content_type): + for customfield in self._get_custom_fields(self._get_content_type()): field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) @@ -89,40 +89,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): return customfield.to_form_field(for_csv_import=True) -class CustomFieldModelBulkEditForm(BulkEditForm): +class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) - self.custom_fields = [] - self.obj_type = ContentType.objects.get_for_model(self.model) - - # Add all applicable CustomFields to the form - custom_fields = CustomField.objects.filter(content_types=self.obj_type) - for cf in custom_fields: + def _append_customfield_fields(self): + """ + Append form fields for all CustomFields assigned to this object type. + """ + for customfield in self._get_custom_fields(self._get_content_type()): # Annotate non-required custom fields as nullable - if not cf.required: - self.nullable_fields.append(cf.name) - self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False) - # Annotate this as a custom field - self.custom_fields.append(cf.name) + if not customfield.required: + self.nullable_fields.append(customfield.name) + + self.fields[customfield.name] = self._get_form_field(customfield) + + # Annotate the field in the list of CustomField form fields + self.custom_fields[customfield.name] = customfield -class CustomFieldModelFilterForm(FilterForm): +class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form): + q = forms.CharField( + required=False, + label='Search' + ) - def __init__(self, *args, **kwargs): - - self.obj_type = ContentType.objects.get_for_model(self.model) - - super().__init__(*args, **kwargs) - - # Add all applicable CustomFields to the form - self.custom_field_filters = [] - custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(type=CustomFieldTypeChoices.TYPE_JSON) ) - for cf in custom_fields: - field_name = f'cf_{cf.name}' - self.fields[field_name] = cf.to_form_field(set_initial=False, enforce_required=False) - self.custom_field_filters.append(field_name) + + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index adfccb575..7d60518b2 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -58,7 +58,7 @@ class CustomFieldTable(BaseTable): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', + 'description', 'filter_logic', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description') @@ -80,7 +80,7 @@ class CustomLinkTable(BaseTable): model = CustomLink fields = ( 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', - 'button_class', 'new_window', + 'button_class', 'new_window', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') @@ -101,6 +101,7 @@ class ExportTemplateTable(BaseTable): model = ExportTemplate fields = ( 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', @@ -135,7 +136,7 @@ class WebhookTable(BaseTable): model = Webhook fields = ( 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', - 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', + 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', @@ -156,7 +157,7 @@ class TagTable(BaseTable): class Meta(BaseTable.Meta): model = Tag - fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions') + fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') @@ -193,7 +194,8 @@ class ConfigContextTable(BaseTable): model = ConfigContext fields = ( 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 13ae0f54f..9d6fb5edc 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -125,11 +125,30 @@ class ASN(PrimaryModel): verbose_name_plural = 'ASNs' def __str__(self): - return f'AS{self.asn}' + return f'AS{self.asn_with_asdot}' def get_absolute_url(self): return reverse('ipam:asn', args=[self.pk]) + @property + def asn_asdot(self): + """ + Return ASDOT notation for AS numbers greater than 16 bits. + """ + if self.asn > 65535: + return f'{self.asn // 65536}.{self.asn % 65536}' + return self.asn + + @property + def asn_with_asdot(self): + """ + Return both plain and ASDOT notation, where applicable. + """ + if self.asn > 65535: + return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})' + else: + return self.asn + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index a691b945b..f9119126c 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -38,7 +38,7 @@ class FHRPGroupTable(BaseTable): model = FHRPGroup fields = ( 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 9914fb22b..b2e4ef958 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -91,7 +91,10 @@ class RIRTable(BaseTable): class Meta(BaseTable.Meta): model = RIR - fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'created', + 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description') @@ -102,8 +105,10 @@ class RIRTable(BaseTable): class ASNTable(BaseTable): pk = ToggleColumn() asn = tables.Column( + accessor=tables.A('asn_asdot'), linkify=True ) + site_count = LinkedCountColumn( viewname='dcim:site_list', url_params={'asn_id': 'pk'}, @@ -112,7 +117,7 @@ class ASNTable(BaseTable): class Meta(BaseTable.Meta): model = ASN - fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') + fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant') @@ -144,7 +149,10 @@ class AggregateTable(BaseTable): class Meta(BaseTable.Meta): model = Aggregate - fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags') + fields = ( + 'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') @@ -173,7 +181,10 @@ class RoleTable(BaseTable): class Meta(BaseTable.Meta): model = Role - fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'created', + 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description') @@ -260,8 +271,8 @@ class PrefixTable(BaseTable): class Meta(BaseTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group', - 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', + 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -302,7 +313,7 @@ class IPRangeTable(BaseTable): model = IPRange fields = ( 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', - 'utilization', 'tags', + 'utilization', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -360,7 +371,7 @@ class IPAddressTable(BaseTable): model = IPAddress fields = ( 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 783cb3537..5c3e14b2c 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -45,5 +45,8 @@ class ServiceTable(BaseTable): class Meta(BaseTable.Meta): model = Service - fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 3454ddff4..d387e24dd 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -85,7 +85,7 @@ class VLANGroupTable(BaseTable): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', - 'tags', 'actions', + 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') @@ -127,7 +127,10 @@ class VLANTable(BaseTable): class Meta(BaseTable.Meta): model = VLAN - fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags') + fields = ( + 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, VLAN) else '', diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 1264368f4..e71fb1fa4 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -47,7 +47,8 @@ class VRFTable(BaseTable): class Meta(BaseTable.Meta): model = VRF fields = ( - 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags', + 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'rd', 'tenant', 'description') @@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable): class Meta(BaseTable.Meta): model = RouteTarget - fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags') + fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index d76bc598d..e9b213a95 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -287,7 +287,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def _update_objects(self, form, request): custom_fields = getattr(form, 'custom_fields', []) standard_fields = [ - field for field in form.fields if field not in custom_fields + ['pk'] + field for field in form.fields if field not in list(custom_fields) + ['pk'] ] nullified_fields = request.POST.getlist('_nullify') updated_objects = [] diff --git a/netbox/templates/inc/filter_list.html b/netbox/templates/inc/filter_list.html index 1e73fedb2..e6a1e6a28 100644 --- a/netbox/templates/inc/filter_list.html +++ b/netbox/templates/inc/filter_list.html @@ -24,17 +24,17 @@ {% else %} {# List all non-customfield filters as declared in the form class #} {% for field in filter_form.visible_fields %} - {% if not filter_form.custom_field_filters or field.name not in filter_form.custom_field_filters %} + {% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %}
    {% render_field field %}
    {% endif %} {% endfor %} {% endif %} - {% if filter_form.custom_field_filters %} + {% if filter_form.custom_fields %} {# List all custom field filters #}
    - {% for name in filter_form.custom_field_filters %} + {% for name in filter_form.custom_fields %}
    {% with field=filter_form|get_item:name %} {% render_field field %} diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 53afd5ebb..4a1ecda0d 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -18,7 +18,7 @@ - + diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index f15e67eab..55a0591b5 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -62,7 +62,9 @@ class TenantGroupTable(BaseTable): class Meta(BaseTable.Meta): model = TenantGroup - fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'tenant_count', 'description') @@ -81,7 +83,7 @@ class TenantTable(BaseTable): class Meta(BaseTable.Meta): model = Tenant - fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags') + fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'group', 'description') @@ -105,7 +107,9 @@ class ContactGroupTable(BaseTable): class Meta(BaseTable.Meta): model = ContactGroup - fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'contact_count', 'description') @@ -117,7 +121,7 @@ class ContactRoleTable(BaseTable): class Meta(BaseTable.Meta): model = ContactRole - fields = ('pk', 'name', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'description', 'slug', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'description') @@ -142,7 +146,10 @@ class ContactTable(BaseTable): class Meta(BaseTable.Meta): model = Contact - fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags') + fields = ( + 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 87fa4ae33..88f837b2b 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -3,7 +3,6 @@ import re import yaml from django import forms -from django.utils.translation import gettext as _ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect @@ -11,6 +10,7 @@ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSel __all__ = ( 'BootstrapMixin', 'BulkEditForm', + 'BulkEditBaseForm', 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', @@ -75,11 +75,10 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) -class BulkEditForm(BootstrapMixin, forms.Form): +class BulkEditBaseForm(forms.Form): """ Base form for editing multiple objects in bulk """ - def __init__(self, model, *args, **kwargs): super().__init__(*args, **kwargs) self.model = model @@ -90,6 +89,10 @@ class BulkEditForm(BootstrapMixin, forms.Form): self.nullable_fields = self.Meta.nullable_fields +class BulkEditForm(BootstrapMixin, BulkEditBaseForm): + pass + + class BulkRenameForm(BootstrapMixin, forms.Form): """ An extendable form to be used for renaming objects in bulk. @@ -185,10 +188,7 @@ class FilterForm(BootstrapMixin, forms.Form): """ q = forms.CharField( required=False, - widget=forms.TextInput( - attrs={'placeholder': _('All fields')} - ), - label=_('Search') + label='Search' ) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 1920c1140..517f0a4b8 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -44,7 +44,9 @@ class ClusterTypeTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterType - fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'created', 'last_updated', 'tags', 'actions', + ) default_columns = ('pk', 'name', 'cluster_count', 'description') @@ -66,7 +68,9 @@ class ClusterGroupTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterGroup - fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'cluster_count', 'description') @@ -108,7 +112,10 @@ class ClusterTable(BaseTable): class Meta(BaseTable.Meta): model = Cluster - fields = ('pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags') + fields = ( + 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') @@ -149,8 +156,8 @@ class VirtualMachineTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', - 'primary_ip6', 'primary_ip', 'comments', 'tags', + 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', @@ -177,7 +184,7 @@ class VMInterfaceTable(BaseInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 67d46f248..650d91554 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -27,7 +27,9 @@ class WirelessLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLANGroup - fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'wirelesslan_count', 'description') @@ -50,7 +52,7 @@ class WirelessLANTable(BaseTable): model = WirelessLAN fields = ( 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') @@ -99,7 +101,7 @@ class WirelessLinkTable(BaseTable): model = WirelessLink fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', - 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', diff --git a/requirements.txt b/requirements.txt index e0148e74e..83c1b9f2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Django==3.2.11 -django-cors-headers==3.10.1 +django-cors-headers==3.11.0 django-debug-toolbar==3.2.4 django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 @@ -10,7 +10,7 @@ django-redis==5.2.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.0.0 -django-timezone-field==4.2.1 +django-timezone-field==4.2.3 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 graphene_django==2.15.0 @@ -18,7 +18,7 @@ gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==8.1.4 +mkdocs-material==8.1.7 netaddr==0.8.0 Pillow==8.4.0 psycopg2-binary==2.9.3 From 3fcae36cf15c141a59001b7057933e0bbe9b428f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 18 Jan 2022 16:57:54 -0500 Subject: [PATCH 071/104] Closes #8307: Add data_type indicator to REST API serializer for custom fields --- docs/release-notes/version-3.2.md | 1 + netbox/extras/api/serializers.py | 19 ++++- netbox/extras/tests/test_customfields.py | 95 +++++++++++++++++++----- 3 files changed, 93 insertions(+), 22 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index dec5843dc..09701f800 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -71,6 +71,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group * [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links +* [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields ### Other Changes diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 79fab4a90..36b307b39 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -79,15 +79,28 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) type = ChoiceField(choices=CustomFieldTypeChoices) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) + data_type = serializers.SerializerMethodField() class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', - 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', - 'last_updated', + 'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required', + 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choices', 'created', 'last_updated', ] + def get_data_type(self, obj): + types = CustomFieldTypeChoices + if obj.type == types.TYPE_INTEGER: + return 'integer' + if obj.type == types.TYPE_BOOLEAN: + return 'boolean' + if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): + return 'object' + if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT): + return 'array' + return 'string' + # # Custom links diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 1a1fc13a8..3a5fe3ac9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -378,9 +378,22 @@ class CustomFieldAPITest(APITestCase): CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'), - CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=( - 'Foo', 'Bar', 'Baz' - )), + CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT, + name='select_field', + default='Foo', + choices=( + 'Foo', 'Bar', 'Baz' + ) + ), + CustomField( + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + name='multiselect_field', + default=['Foo'], + choices=( + 'Foo', 'Bar', 'Baz' + ) + ), CustomField( type=CustomFieldTypeChoices.TYPE_OBJECT, name='object_field', @@ -416,11 +429,37 @@ class CustomFieldAPITest(APITestCase): custom_fields[5].name: 'http://example.com/2', custom_fields[6].name: '{"foo": 1, "bar": 2}', custom_fields[7].name: 'Bar', - custom_fields[8].name: vlans[1].pk, - custom_fields[9].name: [vlans[2].pk, vlans[3].pk], + custom_fields[8].name: ['Bar', 'Baz'], + custom_fields[9].name: vlans[1].pk, + custom_fields[10].name: [vlans[2].pk, vlans[3].pk], } sites[1].save() + def test_get_custom_fields(self): + TYPES = { + CustomFieldTypeChoices.TYPE_TEXT: 'string', + CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', + CustomFieldTypeChoices.TYPE_INTEGER: 'integer', + CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', + CustomFieldTypeChoices.TYPE_DATE: 'string', + CustomFieldTypeChoices.TYPE_URL: 'string', + CustomFieldTypeChoices.TYPE_JSON: 'object', + CustomFieldTypeChoices.TYPE_SELECT: 'string', + CustomFieldTypeChoices.TYPE_MULTISELECT: 'array', + CustomFieldTypeChoices.TYPE_OBJECT: 'object', + CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array', + } + + self.add_permissions('extras.view_customfield') + url = reverse('extras-api:customfield-list') + response = self.client.get(url, **self.header) + self.assertEqual(response.data['count'], len(TYPES)) + + # Validate data types + for customfield in response.data['results']: + cf_type = customfield['type']['value'] + self.assertEqual(customfield['data_type'], TYPES[cf_type]) + def test_get_single_object_without_custom_field_data(self): """ Validate that custom fields are present on an object even if it has no values defined. @@ -439,7 +478,8 @@ class CustomFieldAPITest(APITestCase): 'date_field': None, 'url_field': None, 'json_field': None, - 'choice_field': None, + 'select_field': None, + 'multiselect_field': None, 'object_field': None, 'multiobject_field': None, }) @@ -462,7 +502,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) - self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) + self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field']) + self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field']) self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], @@ -495,7 +536,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) - self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) + self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], @@ -511,7 +553,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) @@ -530,7 +573,8 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'choice_field': 'Bar', + 'select_field': 'Bar', + 'multiselect_field': ['Bar', 'Baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), }, @@ -551,7 +595,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) - self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) + self.assertEqual(response_cf['select_field'], data_cf['select_field']) + self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field']) self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -567,7 +612,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field']) self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field']) @@ -611,7 +657,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) - self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) + self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -627,7 +674,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) @@ -643,7 +691,8 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'choice_field': 'Bar', + 'select_field': 'Bar', + 'multiselect_field': ['Bar', 'Baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), } @@ -682,7 +731,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) - self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field']) + self.assertEqual(response_cf['select_field'], custom_field_data['select_field']) + self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], custom_field_data['multiobject_field'] @@ -697,7 +748,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field']) + self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field']) def test_update_single_object_with_values(self): @@ -728,7 +781,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) - self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) + self.assertEqual(response_cf['select_field'], original_cfvs['select_field']) + self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], original_cfvs['multiobject_field'] @@ -743,7 +798,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field']) + self.assertEqual(site2.custom_field_data['select_field'], original_cfvs['select_field']) + self.assertEqual(site2.custom_field_data['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field']) self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) def test_minimum_maximum_values_validation(self): From bf6345aa90afd5ee5c7a59ae6fd1d4ad73509803 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 09:14:38 -0500 Subject: [PATCH 072/104] Closes #5429: Enable toggling the placement of table paginators --- docs/development/user-preferences.md | 1 + docs/release-notes/version-3.2.md | 1 + netbox/netbox/preferences.py | 10 ++++++++++ netbox/templates/htmx/table.html | 12 ++++++++++-- netbox/users/forms.py | 1 + 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index 622fbb4b9..ceb5321a9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -8,6 +8,7 @@ The `users.UserConfig` model holds individual preferences for each user in the f |--------------------------|---------------------------------------------------------------| | data_format | Preferred format when rendering raw data (JSON or YAML) | | pagination.per_page | The number of items to display per page of a paginated table | +| pagination.placement | Where to display the paginator controls relative to the table | | tables.${table}.columns | The ordered list of columns to display when viewing the table | | tables.${table}.ordering | A list of column names by which the table should be ordered | | ui.colormode | Light or dark mode in the user interface | diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 09701f800..432d6fafc 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -60,6 +60,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements +* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators * [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 4cad8cf24..aec8bc752 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -26,6 +26,16 @@ PREFERENCES = { description='The number of objects to display per page', coerce=lambda x: int(x) ), + 'pagination.placement': UserPreference( + label='Paginator placement', + choices=( + ('bottom', 'Bottom'), + ('top', 'Top'), + ('both', 'Both'), + ), + description='Where the paginator controls will be displayed relative to a table', + default='bottom' + ), # Miscellaneous 'data_format': UserPreference( diff --git a/netbox/templates/htmx/table.html b/netbox/templates/htmx/table.html index c5d0ac46b..6f168ac52 100644 --- a/netbox/templates/htmx/table.html +++ b/netbox/templates/htmx/table.html @@ -1,5 +1,13 @@ {# Render an HTMX-enabled table with paginator #} +{% load helpers %} {% load render_table from django_tables2 %} -{% render_table table 'inc/table_htmx.html' %} -{% include 'inc/paginator_htmx.html' with paginator=table.paginator page=table.page %} +{% with preferences|get_key:"pagination.placement" as paginator_placement %} + {% if paginator_placement == 'top' or paginator_placement == 'both' %} + {% include 'inc/paginator_htmx.html' with paginator=table.paginator page=table.page %} + {% endif %} + {% render_table table 'inc/table_htmx.html' %} + {% if paginator_placement != 'top' %} + {% include 'inc/paginator_htmx.html' with paginator=table.paginator page=table.page %} + {% endif %} +{% endwith %} diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 70e300a8c..5a99adc5a 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -47,6 +47,7 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe fieldsets = ( ('User Interface', ( 'pagination.per_page', + 'pagination.placement', 'ui.colormode', )), ('Miscellaneous', ( From c7825e391cabbdcc7616e78d70313957fee38b25 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 14:46:50 -0500 Subject: [PATCH 073/104] Designate feature mixin classes & employ class_prepared signal to register features --- netbox/extras/utils.py | 23 ++- netbox/netbox/models/__init__.py | 129 +++++++++++++ .../netbox/{models.py => models/features.py} | 171 ++++++------------ 3 files changed, 198 insertions(+), 125 deletions(-) create mode 100644 netbox/netbox/models/__init__.py rename netbox/netbox/{models.py => models/features.py} (57%) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index ace49cce5..16749ad5d 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -57,21 +57,24 @@ class FeatureQuery: return query +def register_features(model, features): + if 'model_features' not in registry: + registry['model_features'] = { + f: collections.defaultdict(list) for f in EXTRAS_FEATURES + } + for feature in features: + if feature not in EXTRAS_FEATURES: + raise ValueError(f"{feature} is not a valid extras feature!") + app_label, model_name = model._meta.label_lower.split('.') + registry['model_features'][feature][app_label].append(model_name) + + def extras_features(*features): """ Decorator used to register extras provided features to a model """ def wrapper(model_class): # Initialize the model_features store if not already defined - if 'model_features' not in registry: - registry['model_features'] = { - f: collections.defaultdict(list) for f in EXTRAS_FEATURES - } - for feature in features: - if feature in EXTRAS_FEATURES: - app_label, model_name = model_class._meta.label_lower.split('.') - registry['model_features'][feature][app_label].append(model_name) - else: - raise ValueError('{} is not a valid extras feature!'.format(feature)) + register_features(model_class, features) return model_class return wrapper diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py new file mode 100644 index 000000000..e715cf329 --- /dev/null +++ b/netbox/netbox/models/__init__.py @@ -0,0 +1,129 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.core.validators import ValidationError +from django.db import models +from mptt.models import MPTTModel, TreeForeignKey + +from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet +from netbox.models.features import * + +__all__ = ( + 'BigIDModel', + 'ChangeLoggedModel', + 'NestedGroupModel', + 'OrganizationalModel', + 'PrimaryModel', +) + + +# +# Base model classes +# + +class BigIDModel(models.Model): + """ + Abstract base model for all data objects. Ensures the use of a 64-bit PK. + """ + id = models.BigAutoField( + primary_key=True + ) + + class Meta: + abstract = True + + +class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): + """ + Base model for all objects which support change logging. + """ + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): + """ + Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest + recursively using MPTT. Within each parent, each child instance must have a unique name. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = TreeManager() + + class Meta: + abstract = True + + class MPTTMeta: + order_insertion_by = ('name',) + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # An MPTT model cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({ + "parent": "Cannot assign self as parent." + }) + + +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): + """ + Organizational models are those which are used solely to categorize and qualify other objects, and do not convey + any real information about the infrastructure being modeled (for example, functional device roles). Organizational + models provide the following standard attributes: + - Unique name + - Unique slug (automatically derived from name) + - Optional description + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + ordering = ('name',) diff --git a/netbox/netbox/models.py b/netbox/netbox/models/features.py similarity index 57% rename from netbox/netbox/models.py rename to netbox/netbox/models/features.py index 3e6ebd8b2..99f8c00b7 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models/features.py @@ -1,34 +1,37 @@ import logging -from django.contrib.contenttypes.fields import GenericRelation +from django.db.models.signals import class_prepared +from django.dispatch import receiver + from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models -from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from extras.choices import ObjectChangeActionChoices +from extras.utils import register_features from netbox.signals import post_clean -from utilities.mptt import TreeManager -from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object __all__ = ( - 'BigIDModel', - 'ChangeLoggedModel', - 'NestedGroupModel', - 'OrganizationalModel', - 'PrimaryModel', + 'ChangeLoggingMixin', + 'CustomFieldsMixin', + 'CustomLinksMixin', + 'CustomValidationMixin', + 'ExportTemplatesMixin', + 'JobResultsMixin', + 'TagsMixin', + 'WebhooksMixin', ) # -# Mixins +# Feature mixins # class ChangeLoggingMixin(models.Model): """ - Provides change logging support. + Provides change logging support for a model. Adds the `created` and `last_updated` fields. """ created = models.DateField( auto_now_add=True, @@ -74,7 +77,7 @@ class ChangeLoggingMixin(models.Model): class CustomFieldsMixin(models.Model): """ - Provides support for custom fields. + Enables support for custom fields. """ custom_field_data = models.JSONField( encoder=DjangoJSONEncoder, @@ -128,6 +131,14 @@ class CustomFieldsMixin(models.Model): raise ValidationError(f"Missing required custom field '{cf.name}'.") +class CustomLinksMixin(models.Model): + """ + Enables support for custom links. + """ + class Meta: + abstract = True + + class CustomValidationMixin(models.Model): """ Enables user-configured validation rules for built-in models by extending the clean() method. @@ -142,9 +153,25 @@ class CustomValidationMixin(models.Model): post_clean.send(sender=self.__class__, instance=self) +class ExportTemplatesMixin(models.Model): + """ + Enables support for export templates. + """ + class Meta: + abstract = True + + +class JobResultsMixin(models.Model): + """ + Enable the assignment of JobResults to a model. + """ + class Meta: + abstract = True + + class TagsMixin(models.Model): """ - Enable the assignment of Tags. + Enable the assignment of Tags to a model. """ tags = TaggableManager( through='extras.TaggedItem' @@ -154,113 +181,27 @@ class TagsMixin(models.Model): abstract = True -# -# Base model classes - -class BigIDModel(models.Model): +class WebhooksMixin(models.Model): """ - Abstract base model for all data objects. Ensures the use of a 64-bit PK. + Enables support for webhooks. """ - id = models.BigAutoField( - primary_key=True - ) - class Meta: abstract = True -class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): - """ - Base model for all objects which support change logging. - """ - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True +FEATURES_MAP = ( + ('custom_fields', CustomFieldsMixin), + ('custom_links', CustomLinksMixin), + ('export_templates', ExportTemplatesMixin), + ('job_results', JobResultsMixin), + ('tags', TagsMixin), + ('webhooks', WebhooksMixin), +) -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Primary models represent real objects within the infrastructure being modeled. - """ - journal_entries = GenericRelation( - to='extras.JournalEntry', - object_id_field='assigned_object_id', - content_type_field='assigned_object_type' - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - - -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): - """ - Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest - recursively using MPTT. Within each parent, each child instance must have a unique name. - """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = TreeManager() - - class Meta: - abstract = True - - class MPTTMeta: - order_insertion_by = ('name',) - - def __str__(self): - return self.name - - def clean(self): - super().clean() - - # An MPTT model cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({ - "parent": "Cannot assign self as parent." - }) - - -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Organizational models are those which are used solely to categorize and qualify other objects, and do not convey - any real information about the infrastructure being modeled (for example, functional device roles). Organizational - models provide the following standard attributes: - - Unique name - - Unique slug (automatically derived from name) - - Optional description - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - ordering = ('name',) +@receiver(class_prepared) +def _register_features(sender, **kwargs): + features = { + feature for feature, cls in FEATURES_MAP if issubclass(sender, cls) + } + register_features(sender, features) From cdae0c2bef3f5cfe81acf0bf3927ed5d98a3650d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 15:16:10 -0500 Subject: [PATCH 074/104] Remove extras_features() decorator --- netbox/circuits/models/circuits.py | 7 ++---- netbox/circuits/models/providers.py | 3 --- netbox/dcim/models/cables.py | 2 -- .../dcim/models/device_component_templates.py | 16 +++--------- netbox/dcim/models/device_components.py | 12 --------- netbox/dcim/models/devices.py | 9 ------- netbox/dcim/models/power.py | 3 --- netbox/dcim/models/racks.py | 4 --- netbox/dcim/models/sites.py | 6 ----- netbox/extras/constants.py | 1 + netbox/extras/models/configcontexts.py | 5 ++-- netbox/extras/models/customfields.py | 6 ++--- netbox/extras/models/models.py | 24 +++++++----------- netbox/extras/models/tags.py | 5 ++-- netbox/extras/utils.py | 11 -------- netbox/ipam/models/fhrp.py | 6 ++--- netbox/ipam/models/ip.py | 8 ------ netbox/ipam/models/services.py | 3 --- netbox/ipam/models/vlans.py | 3 --- netbox/ipam/models/vrfs.py | 3 --- netbox/netbox/models/__init__.py | 25 +++++++++++-------- netbox/netbox/models/features.py | 17 +++++++++++++ netbox/tenancy/models/contacts.py | 8 ++---- netbox/tenancy/models/tenants.py | 3 --- netbox/virtualization/models.py | 7 ------ netbox/wireless/models.py | 4 --- 26 files changed, 58 insertions(+), 143 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 013aef557..e697caa0a 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,8 +5,8 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models.features import WebhooksMixin __all__ = ( 'Circuit', @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -44,7 +43,6 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple @@ -138,8 +136,7 @@ class Circuit(PrimaryModel): return CircuitStatusChoices.colors.get(self.status, 'secondary') -@extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, LinkTermination): +class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 153e241a7..8fd52c587 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from extras.utils import extras_features from netbox.models import PrimaryModel __all__ = ( @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -72,7 +70,6 @@ class Provider(PrimaryModel): return reverse('circuits:provider', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ProviderNetwork(PrimaryModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 12fe91036..18bf65895 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,7 +11,6 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField from utilities.utils import to_meters @@ -29,7 +28,6 @@ __all__ = ( # Cables # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Cable(PrimaryModel): """ A physical connection between two endpoints. diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b3ede8282..72ac9df40 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -7,8 +7,8 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -32,7 +32,7 @@ __all__ = ( ) -class ComponentTemplateModel(ChangeLoggedModel): +class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -135,7 +135,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel): return self.name -@extras_features('webhooks') class ConsolePortTemplate(ModularComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -164,7 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ConsoleServerPortTemplate(ModularComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -193,7 +191,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class PowerPortTemplate(ModularComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -245,7 +242,6 @@ class PowerPortTemplate(ModularComponentTemplateModel): }) -@extras_features('webhooks') class PowerOutletTemplate(ModularComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -307,7 +303,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class InterfaceTemplate(ModularComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -347,7 +342,6 @@ class InterfaceTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -420,7 +414,6 @@ class FrontPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class RearPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -460,7 +453,6 @@ class RearPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ModuleBayTemplate(ComponentTemplateModel): """ A template for a ModuleBay to be created for a new parent Device. @@ -486,7 +478,6 @@ class ModuleBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. @@ -511,7 +502,6 @@ class DeviceBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): """ A template for an InventoryItem to be created for a new parent Device. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 916161ced..c26b32575 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,7 +11,6 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG -from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -254,7 +253,6 @@ class PathEndpoint(models.Model): # Console components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -282,7 +280,6 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -314,7 +311,6 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): # Power components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -407,7 +403,6 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): } -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -522,7 +517,6 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. @@ -793,7 +787,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo # Pass-through ports # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. @@ -847,7 +840,6 @@ class FrontPort(ModularComponentModel, LinkTermination): }) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. @@ -891,7 +883,6 @@ class RearPort(ModularComponentModel, LinkTermination): # Bays # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ModuleBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -912,7 +903,6 @@ class ModuleBay(ComponentModel): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -963,7 +953,6 @@ class DeviceBay(ComponentModel): # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItemRole(OrganizationalModel): """ Inventory items may optionally be assigned a functional role. @@ -994,7 +983,6 @@ class InventoryItemRole(OrganizationalModel): return reverse('dcim:inventoryitemrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 631f0c8c1..f94c9757d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -13,7 +13,6 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices @@ -37,7 +36,6 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -70,7 +68,6 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceType(PrimaryModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -353,7 +350,6 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ModuleType(PrimaryModel): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional @@ -487,7 +483,6 @@ class ModuleType(PrimaryModel): # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -525,7 +520,6 @@ class DeviceRole(OrganizationalModel): return reverse('dcim:devicerole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". @@ -575,7 +569,6 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -1012,7 +1005,6 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Module(PrimaryModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components @@ -1095,7 +1087,6 @@ class Module(PrimaryModel, ConfigContextModel): # Virtual chassis # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index e3146c167..fe7f69df9 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,7 +6,6 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import PrimaryModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -21,7 +20,6 @@ __all__ = ( # Power # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. @@ -68,7 +66,6 @@ class PowerPanel(PrimaryModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c324d4cba..1ebbbcba4 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -13,7 +13,6 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from extras.utils import extras_features from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices @@ -34,7 +33,6 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. @@ -65,7 +63,6 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Rack(PrimaryModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. @@ -438,7 +435,6 @@ class Rack(PrimaryModel): return int(allocated_draw_total / available_power_total * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a71206224..3756933ac 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,8 +7,6 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from dcim.fields import ASNField -from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField @@ -24,7 +22,6 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ A region represents a geographic collection of sites. For example, you might create regions representing countries, @@ -111,7 +108,6 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and @@ -198,7 +194,6 @@ class SiteGroup(NestedGroupModel): # Sites # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -322,7 +317,6 @@ class Site(PrimaryModel): # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Location(NestedGroupModel): """ A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 64cc82f63..123eb0a45 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -7,6 +7,7 @@ EXTRAS_FEATURES = [ 'custom_links', 'export_templates', 'job_results', + 'journaling', 'tags', 'webhooks' ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 2a14f143f..0dc5d57db 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -5,8 +5,8 @@ from django.db import models from django.urls import reverse from extras.querysets import ConfigContextQuerySet -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.utils import deepmerge @@ -20,8 +20,7 @@ __all__ = ( # Config contexts # -@extras_features('webhooks') -class ConfigContext(ChangeLoggedModel): +class ConfigContext(WebhooksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c0f040300..923d84413 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,8 +12,9 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from extras.choices import * -from extras.utils import FeatureQuery, extras_features +from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities import filters from utilities.forms import ( CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -40,8 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -@extras_features('webhooks', 'export_templates') -class CustomField(ChangeLoggedModel): +class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ab877b99e..7189aed03 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -17,8 +17,9 @@ from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet -from extras.utils import extras_features, FeatureQuery, image_upload +from extras.utils import FeatureQuery, image_upload from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -35,8 +36,7 @@ __all__ = ( ) -@extras_features('webhooks', 'export_templates') -class Webhook(ChangeLoggedModel): +class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or delete in NetBox. The request will contain a representation of the object, which the remote application can act on. @@ -184,8 +184,7 @@ class Webhook(ChangeLoggedModel): return render_jinja2(self.payload_url, context) -@extras_features('webhooks', 'export_templates') -class CustomLink(ChangeLoggedModel): +class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -258,8 +257,7 @@ class CustomLink(ChangeLoggedModel): } -@extras_features('webhooks', 'export_templates') -class ExportTemplate(ChangeLoggedModel): +class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -345,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel): return response -@extras_features('webhooks') -class ImageAttachment(ChangeLoggedModel): +class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. """ @@ -424,8 +421,7 @@ class ImageAttachment(ChangeLoggedModel): return super().to_objectchange(action, related_object=self.parent) -@extras_features('webhooks') -class JournalEntry(ChangeLoggedModel): +class JournalEntry(WebhooksMixin, ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to preserve historical context around an object, and complements NetBox's built-in change logging. For example, you @@ -603,8 +599,7 @@ class ConfigRevision(models.Model): # Custom scripts & reports # -@extras_features('job_results') -class Script(models.Model): +class Script(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for custom scripts. Does not exist in the database. """ @@ -616,8 +611,7 @@ class Script(models.Model): # Reports # -@extras_features('job_results') -class Report(models.Model): +class Report(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for reports. Does not exist in the database. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 2925da652..df8446b9c 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -3,8 +3,8 @@ from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -13,8 +13,7 @@ from utilities.fields import ColorField # Tags # -@extras_features('webhooks', 'export_templates') -class Tag(ChangeLoggedModel, TagBase): +class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY ) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 16749ad5d..487ca3c0b 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -67,14 +67,3 @@ def register_features(model, features): raise ValueError(f"{feature} is not a valid extras feature!") app_label, model_name = model._meta.label_lower.split('.') registry['model_features'][feature][app_label].append(model_name) - - -def extras_features(*features): - """ - Decorator used to register extras provided features to a model - """ - def wrapper(model_class): - # Initialize the model_features store if not already defined - register_features(model_class, features) - return model_class - return wrapper diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 9e721c65f..a0e575e45 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class FHRPGroup(PrimaryModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) @@ -70,8 +69,7 @@ class FHRPGroup(PrimaryModel): return reverse('ipam:fhrpgroup', args=[self.pk]) -@extras_features('webhooks') -class FHRPGroupAssignment(ChangeLoggedModel): +class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): interface_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 9d6fb5edc..44dd84525 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,6 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * from ipam.constants import * @@ -54,7 +53,6 @@ class GetAvailablePrefixesMixin: return available_prefixes.iter_cidrs()[0] -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -90,7 +88,6 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ASN(PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have @@ -150,7 +147,6 @@ class ASN(PrimaryModel): return self.asn -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -253,7 +249,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or @@ -285,7 +280,6 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -563,7 +557,6 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class IPRange(PrimaryModel): """ A range of IP addresses, defined by start and end addresses. @@ -759,7 +752,6 @@ class IPRange(PrimaryModel): return int(float(child_count) / self.size * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 43f8353bc..bd8030a0a 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -4,7 +4,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from netbox.models import PrimaryModel @@ -47,7 +46,6 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ServiceTemplate(ServiceBase, PrimaryModel): """ A template for a Service to be applied to a device or virtual machine. @@ -64,7 +62,6 @@ class ServiceTemplate(ServiceBase, PrimaryModel): return reverse('ipam:servicetemplate', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Service(ServiceBase, PrimaryModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 31c8da2b6..f73571ea9 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -6,7 +6,6 @@ from django.db import models from django.urls import reverse from dcim.models import Interface -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet @@ -20,7 +19,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLANGroup(OrganizationalModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. @@ -118,7 +116,6 @@ class VLANGroup(OrganizationalModel): return None -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLAN(PrimaryModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index 11fab9c44..f1b2d682f 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,7 +1,6 @@ from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.constants import * from netbox.models import PrimaryModel @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VRF(PrimaryModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -75,7 +73,6 @@ class VRF(PrimaryModel): return reverse('ipam:vrf', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RouteTarget(PrimaryModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index e715cf329..e38412221 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -20,6 +19,18 @@ __all__ = ( # Base model classes # +class BaseModel( + CustomFieldsMixin, + CustomLinksMixin, + ExportTemplatesMixin, + JournalingMixin, + TagsMixin, + WebhooksMixin, +): + class Meta: + abstract = True + + class BigIDModel(models.Model): """ Abstract base model for all data objects. Ensures the use of a 64-bit PK. @@ -42,23 +53,17 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ - journal_entries = GenericRelation( - to='extras.JournalEntry', - object_id_field='assigned_object_id', - content_type_field='assigned_object_type' - ) - objects = RestrictedQuerySet.as_manager() class Meta: abstract = True -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -100,7 +105,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi }) -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 99f8c00b7..ed0e481ad 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,5 +1,6 @@ import logging +from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -20,6 +21,7 @@ __all__ = ( 'CustomValidationMixin', 'ExportTemplatesMixin', 'JobResultsMixin', + 'JournalingMixin', 'TagsMixin', 'WebhooksMixin', ) @@ -169,6 +171,20 @@ class JobResultsMixin(models.Model): abstract = True +class JournalingMixin(models.Model): + """ + Enables support for JournalEntry assignment. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + + class Meta: + abstract = True + + class TagsMixin(models.Model): """ Enable the assignment of Tags to a model. @@ -194,6 +210,7 @@ FEATURES_MAP = ( ('custom_links', CustomLinksMixin), ('export_templates', ExportTemplatesMixin), ('job_results', JobResultsMixin), + ('journaling', JournalingMixin), ('tags', TagsMixin), ('webhooks', WebhooksMixin), ) diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 42a7ffe7d..ecc599021 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,8 +4,8 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from netbox.models.features import WebhooksMixin from tenancy.choices import * __all__ = ( @@ -16,7 +16,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. @@ -50,7 +49,6 @@ class ContactGroup(NestedGroupModel): return reverse('tenancy:contactgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. @@ -78,7 +76,6 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. @@ -129,8 +126,7 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -@extras_features('webhooks') -class ContactAssignment(ChangeLoggedModel): +class ContactAssignment(WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index d480f9112..9952a700d 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel __all__ = ( @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. @@ -45,7 +43,6 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b19715127..d2f513f0b 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,7 +7,6 @@ from django.urls import reverse from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField @@ -15,7 +14,6 @@ from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from .choices import * - __all__ = ( 'Cluster', 'ClusterGroup', @@ -29,7 +27,6 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -61,7 +58,6 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. @@ -104,7 +100,6 @@ class ClusterGroup(OrganizationalModel): # Clusters # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -188,7 +183,6 @@ class Cluster(PrimaryModel): # Virtual machines # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. @@ -351,7 +345,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # Interfaces # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VMInterface(PrimaryModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 2fcfc97aa..843462ec6 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,7 +5,6 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from extras.utils import extras_features from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel from .choices import * from .constants import * @@ -41,7 +40,6 @@ class WirelessAuthenticationBase(models.Model): abstract = True -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLANGroup(NestedGroupModel): """ A nested grouping of WirelessLANs @@ -81,7 +79,6 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. @@ -120,7 +117,6 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslan', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. From 047bed2a865bcc69f0702ad9b06f664ab9ccc3c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 15:22:28 -0500 Subject: [PATCH 075/104] Tweak registry initialization --- netbox/extras/registry.py | 13 +++++++++++-- netbox/extras/utils.py | 8 +------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index cb58f5135..07fd4cc24 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -1,3 +1,8 @@ +import collections + +from extras.constants import EXTRAS_FEATURES + + class Registry(dict): """ Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or @@ -7,15 +12,19 @@ class Registry(dict): try: return super().__getitem__(key) except KeyError: - raise KeyError("Invalid store: {}".format(key)) + raise KeyError(f"Invalid store: {key}") def __setitem__(self, key, value): if key in self: - raise KeyError("Store already set: {}".format(key)) + raise KeyError(f"Store already set: {key}") super().__setitem__(key, value) def __delitem__(self, key): raise TypeError("Cannot delete stores from registry") +# Initialize the global registry registry = Registry() +registry['model_features'] = { + feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES +} diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 487ca3c0b..e16807821 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -import collections - from django.db.models import Q from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager @@ -58,12 +56,8 @@ class FeatureQuery: def register_features(model, features): - if 'model_features' not in registry: - registry['model_features'] = { - f: collections.defaultdict(list) for f in EXTRAS_FEATURES - } for feature in features: if feature not in EXTRAS_FEATURES: raise ValueError(f"{feature} is not a valid extras feature!") app_label, model_name = model._meta.label_lower.split('.') - registry['model_features'][feature][app_label].append(model_name) + registry['model_features'][feature][app_label].add(model_name) From dd552264559c968c94a725c9558a4fd01cd039c4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:44:18 -0500 Subject: [PATCH 076/104] Draft documentation for model features --- docs/plugins/development/index.md | 3 ++ docs/plugins/development/model-features.md | 37 ++++++++++++++++++++++ mkdocs.yml | 18 ++++++++++- netbox/netbox/models/features.py | 24 +++++++++++--- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 docs/plugins/development/index.md create mode 100644 docs/plugins/development/model-features.md diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md new file mode 100644 index 000000000..31ce5fc2e --- /dev/null +++ b/docs/plugins/development/index.md @@ -0,0 +1,3 @@ +# Plugins Development + +TODO diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md new file mode 100644 index 000000000..83f9b4205 --- /dev/null +++ b/docs/plugins/development/model-features.md @@ -0,0 +1,37 @@ +# Model Features + +Plugin models can leverage certain NetBox features by inheriting from designated Python classes (documented below), defined in `netbox.models.features`. These classes perform two crucial functions: + +1. Apply any fields, methods, or attributes necessary to the operation of the feature +2. Register the model with NetBox as utilizing the feature + +For example, to enable support for tags in a plugin model, it should inherit from `TagsMixin`: + +```python +# models.py +from django.db.models import models +from netbox.models.features import TagsMixin + +class MyModel(TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +This will ensure that TaggableManager is applied to the model, and that the model is registered with NetBox as taggable. + +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. + +::: netbox.models.features.CustomLinksMixin + +::: netbox.models.features.CustomFieldsMixin + +::: netbox.models.features.ExportTemplatesMixin + +::: netbox.models.features.JobResultsMixin + +::: netbox.models.features.JournalingMixin + +::: netbox.models.features.TagsMixin + +::: netbox.models.features.WebhooksMixin diff --git a/mkdocs.yml b/mkdocs.yml index f89bdaea7..764a04c86 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,19 @@ theme: toggle: icon: material/lightbulb name: Switch to Light Mode +plugins: + - mkdocstrings: + handlers: + python: + setup_commands: + - import os + - import django + - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") + - django.setup() + rendering: + show_root_heading: true + show_root_full_path: false + show_root_toc_entry: false extra: social: - icon: fontawesome/brands/github @@ -84,7 +97,10 @@ nav: - Webhooks: 'additional-features/webhooks.md' - Plugins: - Using Plugins: 'plugins/index.md' - - Developing Plugins: 'plugins/development.md' + - Developing Plugins: + - Introduction: 'plugins/development/index.md' + - Model Features: 'plugins/development/model-features.md' + - Developing Plugins (Old): 'plugins/development.md' - Administration: - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ed0e481ad..7865e3c8a 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -93,13 +93,25 @@ class CustomFieldsMixin(models.Model): @property def cf(self): """ - Convenience wrapper for custom field data. + A pass-through convenience alias for accessing `custom_field_data` (read-only). + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.cf + {'cust_id': 'CYB01'} + ``` """ return self.custom_field_data def get_custom_fields(self): """ - Return a dictionary of custom fields for a single object in the form {: value}. + Return a dictionary of custom fields for a single object in the form `{field: value}`. + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.get_custom_fields() + {: 'CYB01'} + ``` """ from extras.models import CustomField @@ -165,7 +177,7 @@ class ExportTemplatesMixin(models.Model): class JobResultsMixin(models.Model): """ - Enable the assignment of JobResults to a model. + Enables support for job results. """ class Meta: abstract = True @@ -173,7 +185,8 @@ class JobResultsMixin(models.Model): class JournalingMixin(models.Model): """ - Enables support for JournalEntry assignment. + Enables support for object journaling. Adds a generic relation (`journal_entries`) + to NetBox's JournalEntry model. """ journal_entries = GenericRelation( to='extras.JournalEntry', @@ -187,7 +200,8 @@ class JournalingMixin(models.Model): class TagsMixin(models.Model): """ - Enable the assignment of Tags to a model. + Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute, + which is a `TaggableManager` instance. """ tags = TaggableManager( through='extras.TaggedItem' From d104544d6f26f54d8982b385b7bf6aa31f86255d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:52:00 -0500 Subject: [PATCH 077/104] Add mkdocstrings --- docs/requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index c8726f8e6..b2e4e9a1b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,3 +5,7 @@ markdown-include # MkDocs Material theme (for documentation build) # https://github.com/squidfunk/mkdocs-material mkdocs-material + +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings From 196784474def8604dda5a74a13a9be29d2708dd9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:58:06 -0500 Subject: [PATCH 078/104] Update documentation requirements --- base_requirements.txt | 4 ++++ docs/requirements.txt | 11 ----------- requirements.txt | 1 + 3 files changed, 5 insertions(+), 11 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/base_requirements.txt b/base_requirements.txt index aaa9c7f44..7ceb344b0 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -82,6 +82,10 @@ markdown-include # https://github.com/squidfunk/mkdocs-material mkdocs-material +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings + # Library for manipulating IP prefixes and addresses # https://github.com/drkjam/netaddr netaddr diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index b2e4e9a1b..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# File inclusion plugin for Python-Markdown -# https://github.com/cmacmackin/markdown-include -markdown-include - -# MkDocs Material theme (for documentation build) -# https://github.com/squidfunk/mkdocs-material -mkdocs-material - -# Introspection for embedded code -# https://github.com/mkdocstrings/mkdocstrings -mkdocstrings diff --git a/requirements.txt b/requirements.txt index 83c1b9f2e..a23be8637 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 mkdocs-material==8.1.7 +mkdocstrings==0.17.0 netaddr==0.8.0 Pillow==8.4.0 psycopg2-binary==2.9.3 From b7682ca9e829102b636864b23eaff15eb4562544 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 09:27:37 -0500 Subject: [PATCH 079/104] Fix documentation build --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 764a04c86..4fdf22f97 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ plugins: setup_commands: - import os - import django + - os.chdir('netbox/') - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - django.setup() rendering: From 1a8f144f5c2ca9d307f8ab7829f595a539206e81 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 10:53:00 -0500 Subject: [PATCH 080/104] Include custom validation in BaseModel --- docs/plugins/development/model-features.md | 46 ++++++++++++++++++---- mkdocs.yml | 1 + netbox/netbox/models/__init__.py | 7 ++-- netbox/netbox/models/features.py | 2 +- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md index 83f9b4205..7b70709d1 100644 --- a/docs/plugins/development/model-features.md +++ b/docs/plugins/development/model-features.md @@ -1,23 +1,51 @@ # Model Features -Plugin models can leverage certain NetBox features by inheriting from designated Python classes (documented below), defined in `netbox.models.features`. These classes perform two crucial functions: +## Enabling NetBox Features -1. Apply any fields, methods, or attributes necessary to the operation of the feature -2. Register the model with NetBox as utilizing the feature +Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: -For example, to enable support for tags in a plugin model, it should inherit from `TagsMixin`: +* Custom fields +* Custom links +* Custom validation +* Export templates +* Job results +* Journaling +* Tags +* Webhooks + +This class performs two crucial functions: + +1. Apply any fields, methods, or attributes necessary to the operation of these features +2. Register the model with NetBox as utilizing these feature + +Simply subclass BaseModel when defining a model in your plugin: ```python # models.py -from django.db.models import models -from netbox.models.features import TagsMixin +from netbox.models import BaseModel -class MyModel(TagsMixin, models.Model): +class MyModel(BaseModel): foo = models.CharField() ... ``` -This will ensure that TaggableManager is applied to the model, and that the model is registered with NetBox as taggable. +## Enabling Features Individually + +If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.) + +```python +# models.py +from django.db.models import models +from netbox.models.features import ExportTemplatesMixin, TagsMixin + +class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.) + +## Feature Mixins Reference !!! note Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. @@ -26,6 +54,8 @@ This will ensure that TaggableManager is applied to the model, and that the mode ::: netbox.models.features.CustomFieldsMixin +::: netbox.models.features.CustomValidationMixin + ::: netbox.models.features.ExportTemplatesMixin ::: netbox.models.features.JobResultsMixin diff --git a/mkdocs.yml b/mkdocs.yml index 4fdf22f97..585e6d76f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ plugins: - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - django.setup() rendering: + heading_level: 3 show_root_heading: true show_root_full_path: false show_root_toc_entry: false diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index e38412221..2db2e2602 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -22,6 +22,7 @@ __all__ = ( class BaseModel( CustomFieldsMixin, CustomLinksMixin, + CustomValidationMixin, ExportTemplatesMixin, JournalingMixin, TagsMixin, @@ -53,7 +54,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -63,7 +64,7 @@ class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDMo abstract = True -class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -105,7 +106,7 @@ class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, Big }) -class OrganizationalModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 7865e3c8a..ce3980459 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -155,7 +155,7 @@ class CustomLinksMixin(models.Model): class CustomValidationMixin(models.Model): """ - Enables user-configured validation rules for built-in models by extending the clean() method. + Enables user-configured validation rules for models. """ class Meta: abstract = True From e6acae5f94485a2eb459e17a0ed008f76fcc7226 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 11:41:00 -0500 Subject: [PATCH 081/104] Omit job results as a supported feature --- docs/plugins/development/model-features.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md index 7b70709d1..35eb9389f 100644 --- a/docs/plugins/development/model-features.md +++ b/docs/plugins/development/model-features.md @@ -8,7 +8,6 @@ Plugin models can leverage certain NetBox features by inheriting from NetBox's ` * Custom links * Custom validation * Export templates -* Job results * Journaling * Tags * Webhooks @@ -58,8 +57,6 @@ The example above will enable export templates and tags, but no other NetBox fea ::: netbox.models.features.ExportTemplatesMixin -::: netbox.models.features.JobResultsMixin - ::: netbox.models.features.JournalingMixin ::: netbox.models.features.TagsMixin From 5f8870d448e26e46eed31c09c1546550b43e4765 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 20 Jan 2022 13:58:11 -0600 Subject: [PATCH 082/104] #7853 - Change Duplex Filterset to allow multivalues --- netbox/dcim/filtersets.py | 4 +++- netbox/dcim/forms/filtersets.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 8a83a8a6b..4dfb080bc 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1197,7 +1197,9 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT label='LAG interface (ID)', ) speed = MultiValueNumberFilter() - duplex = django_filters.CharFilter() + duplex = django_filters.MultipleChoiceFilter( + choices=InterfaceDuplexChoices + ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() tag = TagFilter() diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index ab7b9785a..8868cdf78 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -947,10 +947,11 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='Select Speed', widget=SelectSpeedWidget(attrs={'readonly': None}) ) - duplex = forms.ChoiceField( + duplex = forms.MultipleChoiceField( choices=InterfaceDuplexChoices, required=False, - label='Select Duplex' + label='Select Duplex', + widget=StaticSelectMultiple() ) enabled = forms.NullBooleanField( required=False, From 1a807416b849819758f59b3aafcacc9aad6488b8 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 20 Jan 2022 13:58:37 -0600 Subject: [PATCH 083/104] #7853 - Add columns to tables --- netbox/dcim/tables/devices.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f5ca49187..7b00a16e9 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -524,10 +524,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', - 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', - 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', - 'created', 'last_updated', + 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') From d0bfd7e19acead30eb943bd2e5089ac19cd67d63 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 20 Jan 2022 14:13:13 -0600 Subject: [PATCH 084/104] #7853 - Add tests --- netbox/dcim/tests/test_api.py | 4 ++++ netbox/dcim/tests/test_filtersets.py | 16 ++++++++++++---- netbox/dcim/tests/test_views.py | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1c6f53693..4ab682d74 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1442,6 +1442,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'speed': 1000000, + 'duplex': 'full' }, { 'device': device.pk, @@ -1454,6 +1456,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'speed': 100000, + 'duplex': 'half' }, { 'device': device.pk, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7b2e35009..de4806498 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2383,10 +2383,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0]), - Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1]), - Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2]), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), + Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'), + Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'), + Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'), + Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'), Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), @@ -2423,6 +2423,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_speed(self): + params = {'speed': [1000000, 100000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_duplex(self): + params = {'duplex': ['half', 'full']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_mgmt_only(self): params = {'mgmt_only': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 1b39285d4..4afa8a9f4 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2124,6 +2124,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 65000, + 'speed': 1000000, + 'duplex': 'full', 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -2145,6 +2147,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, + 'speed': 100000, + 'duplex': 'half', 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -2162,6 +2166,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, + 'speed': 1000000, + 'duplex': 'full', 'mgmt_only': True, 'description': 'New description', 'mode': InterfaceModeChoices.MODE_TAGGED, From 54834c47f8870e7faabcd847c3270da0bd3d2884 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 16:31:55 -0500 Subject: [PATCH 085/104] Refactor generic views; add plugins dev documentation --- docs/plugins/development/generic-views.md | 91 +++++++ mkdocs.yml | 1 + netbox/netbox/views/generic/base.py | 15 ++ netbox/netbox/views/generic/bulk_views.py | 225 +++++++++++++--- netbox/netbox/views/generic/object_views.py | 282 +++++--------------- 5 files changed, 360 insertions(+), 254 deletions(-) create mode 100644 docs/plugins/development/generic-views.md create mode 100644 netbox/netbox/views/generic/base.py diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md new file mode 100644 index 000000000..ced7e3807 --- /dev/null +++ b/docs/plugins/development/generic-views.md @@ -0,0 +1,91 @@ +# Generic Views + +NetBox provides several generic view classes to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. + +| View Class | Description | +|------------|-------------| +| `ObjectView` | View a single object | +| `ObjectEditView` | Create or edit a single object | +| `ObjectDeleteView` | Delete a single object | +| `ObjectListView` | View a list of objects | +| `BulkImportView` | Import a set of new objects | +| `BulkEditView` | Edit multiple objects | +| `BulkDeleteView` | Delete multiple objects | + +### Example Usage + +```python +# views.py +from netbox.views.generic import ObjectEditView +from .models import Thing + +class ThingEditView(ObjectEditView): + queryset = Thing.objects.all() + template_name = 'myplugin/thing.html' + ... +``` + +## Generic Views Reference + +Below is the class definition for NetBox's base GenericView. The attributes and methods defined here are available on all generic views. + +::: netbox.views.generic.base.GenericView + rendering: + show_source: false + +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. + +::: netbox.views.generic.ObjectView + selection: + members: + - get_object + - get_template_name + - get_extra_context + rendering: + show_source: false + +::: netbox.views.generic.ObjectEditView + selection: + members: + - get_object + - alter_object + rendering: + show_source: false + +::: netbox.views.generic.ObjectDeleteView + selection: + members: + - get_object + rendering: + show_source: false + +::: netbox.views.generic.ObjectListView + selection: + members: + - get_table + - get_extra_context + - export_yaml + - export_table + - export_template + rendering: + show_source: false + +::: netbox.views.generic.BulkImportView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkEditView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkDeleteView + selection: + members: + - get_form + rendering: + show_source: false diff --git a/mkdocs.yml b/mkdocs.yml index 585e6d76f..c36d3f467 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -102,6 +102,7 @@ nav: - Developing Plugins: - Introduction: 'plugins/development/index.md' - Model Features: 'plugins/development/model-features.md' + - Generic Views: 'plugins/development/generic-views.md' - Developing Plugins (Old): 'plugins/development.md' - Administration: - Authentication: 'administration/authentication.md' diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py new file mode 100644 index 000000000..3861a93aa --- /dev/null +++ b/netbox/netbox/views/generic/base.py @@ -0,0 +1,15 @@ +from django.views.generic import View + +from utilities.views import ObjectPermissionRequiredMixin + + +class GenericView(ObjectPermissionRequiredMixin, View): + """ + Base view class for reusable generic views. + + Attributes: + queryset: Django QuerySet from which the object(s) will be fetched + template_name: The name of the HTML template file to render + """ + queryset = None + template_name = None diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index e9b213a95..c1ae2038e 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,21 +3,27 @@ import re from copy import deepcopy from django.contrib import messages +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea -from django.shortcuts import redirect, render -from django.views.generic import View +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django_tables2.export import TableExport +from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, ) +from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from utilities.tables import configure_table +from utilities.views import GetReturnURLMixin +from .base import GenericView __all__ = ( 'BulkComponentCreateView', @@ -26,24 +32,181 @@ __all__ = ( 'BulkEditView', 'BulkImportView', 'BulkRenameView', + 'ObjectListView', ) -class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectListView(GenericView): + """ + Display multiple objects, all of the same type, as a table. + + Attributes: + filterset: A django-filter FilterSet that is applied to the queryset + filterset_form: The form class used to render filter options + table: The django-tables2 Table used to render the objects list + action_buttons: A list of buttons to include at the top of the page + """ + template_name = 'generic/object_list.html' + filterset = None + filterset_form = None + table = None + action_buttons = ('add', 'import', 'export') + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'view') + + def get_table(self, request, permissions): + """ + Return the django-tables2 Table instance to be used for rendering the objects list. + + Args: + request: The current request + permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating + whether the user has each + """ + table = self.table(self.queryset, user=request.user) + if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): + table.columns.show('pk') + + return table + + def get_extra_context(self, request): + """ + Return any additional context data for the template. + + Agrs: + request: The current request + """ + return {} + + def get(self, request): + """ + GET request handler. + + Args: + request: The current request + """ + model = self.queryset.model + content_type = ContentType.objects.get_for_model(model) + + if self.filterset: + self.queryset = self.filterset(request.GET, self.queryset).qs + + # Compile a dictionary indicating which permissions are available to the current user for this model + permissions = {} + for action in ('add', 'change', 'delete', 'view'): + perm_name = get_permission_for_model(model, action) + permissions[action] = request.user.has_perm(perm_name) + + if 'export' in request.GET: + + # Export the current table view + if request.GET['export'] == 'table': + table = self.get_table(request, permissions) + columns = [name for name, _ in table.selected_columns] + return self.export_table(table, columns) + + # Render an ExportTemplate + elif request.GET['export']: + template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + return self.export_template(template, request) + + # Check for YAML export support on the model + elif hasattr(model, 'to_yaml'): + response = HttpResponse(self.export_yaml(), content_type='text/yaml') + filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response + + # Fall back to default table/YAML export + else: + table = self.get_table(request, permissions) + return self.export_table(table) + + # Render the objects table + table = self.get_table(request, permissions) + configure_table(table, request) + + # If this is an HTMX request, return only the rendered table HTML + if is_htmx(request): + return render(request, 'htmx/table.html', { + 'table': table, + }) + + context = { + 'content_type': content_type, + 'table': table, + 'permissions': permissions, + 'action_buttons': self.action_buttons, + 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, + } + context.update(self.get_extra_context(request)) + + return render(request, self.template_name, context) + + # + # Export methods + # + + def export_yaml(self): + """ + Export the queryset of objects as concatenated YAML documents. + """ + yaml_data = [obj.to_yaml() for obj in self.queryset] + + return '---\n'.join(yaml_data) + + def export_table(self, table, columns=None, filename=None): + """ + Export all table data in CSV format. + + Args: + table: The Table instance to export + columns: A list of specific columns to include. If None, all columns will be exported. + filename: The name of the file attachment sent to the client. If None, will be determined automatically + from the queryset model name. + """ + exclude_columns = {'pk', 'actions'} + if columns: + all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] + exclude_columns.update({ + col for col in all_columns if col not in columns + }) + exporter = TableExport( + export_format=TableExport.CSV, + table=table, + exclude_columns=exclude_columns + ) + return exporter.response( + filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' + ) + + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + + Args: + template: ExportTemplate instance + request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + +class BulkCreateView(GetReturnURLMixin, GenericView): """ Create new objects in bulk. - queryset: Base queryset for the objects being created form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects pattern_target: Name of the field to be evaluated as a pattern (if any) - template_name: The name of the template """ - queryset = None form = None model_form = None pattern_target = '' - template_name = None def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -135,20 +298,18 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkImportView(GetReturnURLMixin, GenericView): """ Import objects in bulk (CSV format). - queryset: Base queryset for the model - model_form: The form used to create each imported object - table: The django-tables2 Table used to render the list of imported objects - template_name: The name of the template - widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) + Attributes: + model_form: The form used to create each imported object + table: The django-tables2 Table used to render the list of imported objects + widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ - queryset = None + template_name = 'generic/object_bulk_import.html' model_form = None table = None - template_name = 'generic/object_bulk_import.html' widget_attrs = {} def _import_form(self, *args, **kwargs): @@ -265,21 +426,19 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkEditView(GetReturnURLMixin, GenericView): """ Edit objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being edited - form: The form class used to edit objects in bulk - template_name: The name of the template + Attributes: + filterset: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being edited + form: The form class used to edit objects in bulk """ - queryset = None + template_name = 'generic/object_bulk_edit.html' filterset = None table = None form = None - template_name = 'generic/object_bulk_edit.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') @@ -422,14 +581,10 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkRenameView(GetReturnURLMixin, GenericView): """ An extendable view for renaming objects in bulk. - - queryset: QuerySet of objects being renamed - template_name: The name of the template """ - queryset = None template_name = 'generic/object_bulk_rename.html' def __init__(self, *args, **kwargs): @@ -513,21 +668,18 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkDeleteView(GetReturnURLMixin, GenericView): """ Delete objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) filterset: FilterSet to apply when deleting by QuerySet table: The table used to display devices being deleted form: The form class used to delete objects in bulk - template_name: The name of the template """ - queryset = None + template_name = 'generic/object_bulk_delete.html' filterset = None table = None form = None - template_name = 'generic/object_bulk_delete.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') @@ -613,18 +765,17 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Device/VirtualMachine components # -class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkComponentCreateView(GetReturnURLMixin, GenericView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ + template_name = 'generic/object_bulk_add_component.html' parent_model = None parent_field = None form = None - queryset = None model_form = None filterset = None table = None - template_name = 'generic/object_bulk_add_component.html' def get_required_permission(self): return f'dcim.add_{self.queryset.model._meta.model_name}' diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index b6df5e3c2..79732572d 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -2,21 +2,16 @@ import logging from copy import deepcopy from django.contrib import messages -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError from django.forms.widgets import HiddenInput -from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe -from django.views.generic import View -from django_tables2.export import TableExport -from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortTransaction, PermissionsViolation @@ -25,7 +20,8 @@ from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from utilities.views import GetReturnURLMixin +from .base import GenericView __all__ = ( 'ComponentCreateView', @@ -33,27 +29,31 @@ __all__ = ( 'ObjectDeleteView', 'ObjectEditView', 'ObjectImportView', - 'ObjectListView', 'ObjectView', ) -class ObjectView(ObjectPermissionRequiredMixin, View): +class ObjectView(GenericView): """ Retrieve a single object for display. - queryset: The base queryset for retrieving the object - template_name: Name of the template to use + Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. """ - queryset = None - template_name = None def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') + def get_object(self, **kwargs): + """ + Return the object being viewed, identified by the keyword arguments passed. If no matching object is found, + raise a 404 error. + """ + return get_object_or_404(self.queryset, **kwargs) + def get_template_name(self): """ - Return self.template_name if set. Otherwise, resolve the template path by model app_label and name. + Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset + model's `app_label` and `model_name`. """ if self.template_name is not None: return self.template_name @@ -64,18 +64,20 @@ class ObjectView(ObjectPermissionRequiredMixin, View): """ Return any additional context data for the template. - :param request: The current request - :param instance: The object being viewed + Args: + request: The current request + instance: The object being viewed """ return {} - def get(self, request, *args, **kwargs): + def get(self, request, **kwargs): """ - GET request handler. *args and **kwargs are passed to identify the object being queried. + GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. - :param request: The current request + Args: + request: The current request """ - instance = get_object_or_404(self.queryset, **kwargs) + instance = self.get_object(**kwargs) return render(request, self.get_template_name(), { 'object': instance, @@ -87,15 +89,12 @@ class ObjectChildrenView(ObjectView): """ Display a table of child objects associated with the parent object. - queryset: The base queryset for retrieving the *parent* object - table: Table class used to render child objects list - template_name: Name of the template to use + Attributes: + table: Table class used to render child objects list """ - queryset = None child_model = None table = None filterset = None - template_name = None def get_children(self, request, parent): """ @@ -110,9 +109,10 @@ class ObjectChildrenView(ObjectView): """ Provides a hook for subclassed views to modify data before initializing the table. - :param request: The current request - :param queryset: The filtered queryset of child objects - :param parent: The parent object + Args: + request: The current request + queryset: The filtered queryset of child objects + parent: The parent object """ return queryset @@ -120,7 +120,7 @@ class ObjectChildrenView(ObjectView): """ GET handler for rendering child objects. """ - instance = get_object_or_404(self.queryset, **kwargs) + instance = self.get_object(**kwargs) child_objects = self.get_children(request, instance) if self.filterset: @@ -152,171 +152,17 @@ class ObjectChildrenView(ObjectView): }) -class ObjectListView(ObjectPermissionRequiredMixin, View): - """ - List a series of objects. - - queryset: The queryset of objects to display. Note: Prefetching related objects is not necessary, as the - table will prefetch objects as needed depending on the columns being displayed. - filterset: A django-filter FilterSet that is applied to the queryset - filterset_form: The form used to render filter options - table: The django-tables2 Table used to render the objects list - template_name: The name of the template - action_buttons: A list of buttons to include at the top of the page - """ - queryset = None - filterset = None - filterset_form = None - table = None - template_name = 'generic/object_list.html' - action_buttons = ('add', 'import', 'export') - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'view') - - def get_table(self, request, permissions): - """ - Return the django-tables2 Table instance to be used for rendering the objects list. - - :param request: The current request - :param permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating - whether the user has each - """ - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') - - return table - - def export_yaml(self): - """ - Export the queryset of objects as concatenated YAML documents. - """ - yaml_data = [obj.to_yaml() for obj in self.queryset] - - return '---\n'.join(yaml_data) - - def export_table(self, table, columns=None): - """ - Export all table data in CSV format. - - :param table: The Table instance to export - :param columns: A list of specific columns to include. If not specified, all columns will be exported. - """ - exclude_columns = {'pk', 'actions'} - if columns: - all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] - exclude_columns.update({ - col for col in all_columns if col not in columns - }) - exporter = TableExport( - export_format=TableExport.CSV, - table=table, - exclude_columns=exclude_columns - ) - return exporter.response( - filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' - ) - - def export_template(self, template, request): - """ - Render an ExportTemplate using the current queryset. - - :param template: ExportTemplate instance - :param request: The current request - """ - try: - return template.render_to_response(self.queryset) - except Exception as e: - messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") - return redirect(request.path) - - def get_extra_context(self, request): - """ - Return any additional context data for the template. - - :param request: The current request - """ - return {} - - def get(self, request): - """ - GET request handler. - - :param request: The current request - """ - model = self.queryset.model - content_type = ContentType.objects.get_for_model(model) - - if self.filterset: - self.queryset = self.filterset(request.GET, self.queryset).qs - - # Compile a dictionary indicating which permissions are available to the current user for this model - permissions = {} - for action in ('add', 'change', 'delete', 'view'): - perm_name = get_permission_for_model(model, action) - permissions[action] = request.user.has_perm(perm_name) - - if 'export' in request.GET: - - # Export the current table view - if request.GET['export'] == 'table': - table = self.get_table(request, permissions) - columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) - - # Render an ExportTemplate - elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) - return self.export_template(template, request) - - # Check for YAML export support on the model - elif hasattr(model, 'to_yaml'): - response = HttpResponse(self.export_yaml(), content_type='text/yaml') - filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - return response - - # Fall back to default table/YAML export - else: - table = self.get_table(request, permissions) - return self.export_table(table) - - # Render the objects table - table = self.get_table(request, permissions) - configure_table(table, request) - - # If this is an HTMX request, return only the rendered table HTML - if is_htmx(request): - return render(request, 'htmx/table.html', { - 'table': table, - }) - - context = { - 'content_type': content_type, - 'table': table, - 'permissions': permissions, - 'action_buttons': self.action_buttons, - 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, - } - context.update(self.get_extra_context(request)) - - return render(request, self.template_name, context) - - -class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectImportView(GetReturnURLMixin, GenericView): """ Import a single object (YAML or JSON format). - queryset: Base queryset for the objects being created - model_form: The ModelForm used to create individual objects - related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects - template_name: The name of the template + Attributes: + model_form: The ModelForm used to create individual objects + related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects """ - queryset = None + template_name = 'generic/object_import.html' model_form = None related_object_forms = dict() - template_name = 'generic/object_import.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -445,17 +291,21 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectEditView(GetReturnURLMixin, GenericView): """ Create or edit a single object. - queryset: The base QuerySet for the object being modified - model_form: The form used to create or edit the object - template_name: The name of the template + Attributes: + model_form: The form used to create or edit the object """ - queryset = None - model_form = None template_name = 'generic/object_edit.html' + model_form = None + + def dispatch(self, request, *args, **kwargs): + # Determine required permission based on whether we are editing an existing object + self._permission_action = 'change' if kwargs else 'add' + + return super().dispatch(request, *args, **kwargs) def get_required_permission(self): # self._permission_action is set by dispatch() to either "add" or "change" depending on whether @@ -466,13 +316,16 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Return an instance for editing. If a PK has been specified, this will be an existing object. - :param kwargs: URL path kwargs + Args: + kwargs: URL path kwargs """ if 'pk' in kwargs: obj = get_object_or_404(self.queryset, **kwargs) + # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): obj.snapshot() + return obj return self.queryset.model() @@ -482,24 +335,20 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): Provides a hook for views to modify an object before it is processed. For example, a parent object can be defined given some parameter from the request URL. - :param obj: The object being edited - :param request: The current request - :param url_args: URL path args - :param url_kwargs: URL path kwargs + Args: + obj: The object being edited + request: The current request + url_args: URL path args + url_kwargs: URL path kwargs """ return obj - def dispatch(self, request, *args, **kwargs): - # Determine required permission based on whether we are editing an existing object - self._permission_action = 'change' if kwargs else 'add' - - return super().dispatch(request, *args, **kwargs) - def get(self, request, *args, **kwargs): """ GET request handler. - :param request: The current request + Args: + request: The current request """ obj = self.get_object(**kwargs) obj = self.alter_object(obj, request, args, kwargs) @@ -519,7 +368,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ POST request handler. - :param request: The current request + Args: + request: The current request """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) @@ -588,14 +438,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectDeleteView(GetReturnURLMixin, GenericView): """ Delete a single object. - - queryset: The base queryset for the object being deleted - template_name: The name of the template """ - queryset = None template_name = 'generic/object_delete.html' def get_required_permission(self): @@ -605,7 +451,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Return an instance for deletion. If a PK has been specified, this will be an existing object. - :param kwargs: URL path kwargs + Args: + kwargs: URL path kwargs """ obj = get_object_or_404(self.queryset, **kwargs) @@ -619,7 +466,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ GET request handler. - :param request: The current request + Args: + request: The current request """ obj = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) @@ -646,7 +494,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ POST request handler. - :param request: The current request + Args: + request: The current request """ logger = logging.getLogger('netbox.views.ObjectDeleteView') obj = self.get_object(**kwargs) @@ -687,14 +536,13 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Device/VirtualMachine components # -class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ComponentCreateView(GetReturnURLMixin, GenericView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ - queryset = None + template_name = 'dcim/component_create.html' form = None model_form = None - template_name = 'dcim/component_create.html' patterned_fields = ('name', 'label') def get_required_permission(self): From e03593d86f3082c19255ae24f39d1ed860a04c4d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 13:56:58 -0500 Subject: [PATCH 086/104] Move get_extra_context() to base views --- docs/plugins/development/generic-views.md | 22 +++++--- netbox/netbox/views/generic/base.py | 33 +++++++++++- netbox/netbox/views/generic/bulk_views.py | 57 ++++++++++----------- netbox/netbox/views/generic/object_views.py | 28 ++++------ 4 files changed, 84 insertions(+), 56 deletions(-) diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md index ced7e3807..d8ad0e7a4 100644 --- a/docs/plugins/development/generic-views.md +++ b/docs/plugins/development/generic-views.md @@ -12,6 +12,9 @@ NetBox provides several generic view classes to facilitate common operations, su | `BulkEditView` | Edit multiple objects | | `BulkDeleteView` | Delete multiple objects | +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. + ### Example Usage ```python @@ -25,23 +28,19 @@ class ThingEditView(ObjectEditView): ... ``` -## Generic Views Reference +## Object Views -Below is the class definition for NetBox's base GenericView. The attributes and methods defined here are available on all generic views. +Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object. -::: netbox.views.generic.base.GenericView +::: netbox.views.generic.base.BaseObjectView rendering: show_source: false -!!! note - Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. - ::: netbox.views.generic.ObjectView selection: members: - get_object - get_template_name - - get_extra_context rendering: show_source: false @@ -60,11 +59,18 @@ Below is the class definition for NetBox's base GenericView. The attributes and rendering: show_source: false +## Multi-Object Views + +Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects. + +::: netbox.views.generic.base.BaseMultiObjectView + rendering: + show_source: false + ::: netbox.views.generic.ObjectListView selection: members: - get_table - - get_extra_context - export_yaml - export_table - export_template diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 3861a93aa..7d7c305dd 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -3,7 +3,7 @@ from django.views.generic import View from utilities.views import ObjectPermissionRequiredMixin -class GenericView(ObjectPermissionRequiredMixin, View): +class BaseObjectView(ObjectPermissionRequiredMixin, View): """ Base view class for reusable generic views. @@ -13,3 +13,34 @@ class GenericView(ObjectPermissionRequiredMixin, View): """ queryset = None template_name = None + + def get_extra_context(self, request, instance): + """ + Return any additional context data to include when rendering the template. + + Args: + request: The current request + instance: The object being viewed + """ + return {} + + +class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): + """ + Base view class for reusable generic views. + + Attributes: + queryset: Django QuerySet from which the object(s) will be fetched + template_name: The name of the HTML template file to render + """ + queryset = None + template_name = None + + def get_extra_context(self, request): + """ + Return any additional context data to include when rendering the template. + + Args: + request: The current request + """ + return {} diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index c1ae2038e..3025818fa 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -23,7 +23,7 @@ from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.tables import configure_table from utilities.views import GetReturnURLMixin -from .base import GenericView +from .base import BaseMultiObjectView __all__ = ( 'BulkComponentCreateView', @@ -36,7 +36,7 @@ __all__ = ( ) -class ObjectListView(GenericView): +class ObjectListView(BaseMultiObjectView): """ Display multiple objects, all of the same type, as a table. @@ -70,15 +70,6 @@ class ObjectListView(GenericView): return table - def get_extra_context(self, request): - """ - Return any additional context data for the template. - - Agrs: - request: The current request - """ - return {} - def get(self, request): """ GET request handler. @@ -139,8 +130,8 @@ class ObjectListView(GenericView): 'permissions': permissions, 'action_buttons': self.action_buttons, 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, + **self.get_extra_context(request), } - context.update(self.get_extra_context(request)) return render(request, self.template_name, context) @@ -196,7 +187,7 @@ class ObjectListView(GenericView): return redirect(request.path) -class BulkCreateView(GetReturnURLMixin, GenericView): +class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): """ Create new objects in bulk. @@ -251,6 +242,7 @@ class BulkCreateView(GetReturnURLMixin, GenericView): 'form': form, 'model_form': model_form, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) def post(self, request): @@ -295,10 +287,11 @@ class BulkCreateView(GetReturnURLMixin, GenericView): 'model_form': model_form, 'obj_type': model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkImportView(GetReturnURLMixin, GenericView): +class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): """ Import objects in bulk (CSV format). @@ -375,6 +368,7 @@ class BulkImportView(GetReturnURLMixin, GenericView): 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) def post(self, request): @@ -423,10 +417,11 @@ class BulkImportView(GetReturnURLMixin, GenericView): 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkEditView(GetReturnURLMixin, GenericView): +class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): """ Edit objects in bulk. @@ -578,10 +573,11 @@ class BulkEditView(GetReturnURLMixin, GenericView): 'table': table, 'obj_type_plural': model._meta.verbose_name_plural, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkRenameView(GetReturnURLMixin, GenericView): +class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): """ An extendable view for renaming objects in bulk. """ @@ -668,7 +664,7 @@ class BulkRenameView(GetReturnURLMixin, GenericView): }) -class BulkDeleteView(GetReturnURLMixin, GenericView): +class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): """ Delete objects in bulk. @@ -684,6 +680,18 @@ class BulkDeleteView(GetReturnURLMixin, GenericView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') + def get_form(self): + """ + Provide a standard bulk delete form if none has been specified for the view + """ + class BulkDeleteForm(ConfirmationForm): + pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) + + if self.form: + return self.form + + return BulkDeleteForm + def get(self, request): return redirect(self.get_return_url(request)) @@ -746,26 +754,15 @@ class BulkDeleteView(GetReturnURLMixin, GenericView): 'obj_type_plural': model._meta.verbose_name_plural, 'table': table, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) - def get_form(self): - """ - Provide a standard bulk delete form if none has been specified for the view - """ - class BulkDeleteForm(ConfirmationForm): - pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) - - if self.form: - return self.form - - return BulkDeleteForm - # # Device/VirtualMachine components # -class BulkComponentCreateView(GetReturnURLMixin, GenericView): +class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 79732572d..c681767c2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -21,7 +21,7 @@ from utilities.permissions import get_permission_for_model from utilities.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin -from .base import GenericView +from .base import BaseObjectView __all__ = ( 'ComponentCreateView', @@ -33,13 +33,12 @@ __all__ = ( ) -class ObjectView(GenericView): +class ObjectView(BaseObjectView): """ Retrieve a single object for display. Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. """ - def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -60,16 +59,6 @@ class ObjectView(GenericView): model_opts = self.queryset.model._meta return f'{model_opts.app_label}/{model_opts.model_name}.html' - def get_extra_context(self, request, instance): - """ - Return any additional context data for the template. - - Args: - request: The current request - instance: The object being viewed - """ - return {} - def get(self, request, **kwargs): """ GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. @@ -152,7 +141,7 @@ class ObjectChildrenView(ObjectView): }) -class ObjectImportView(GetReturnURLMixin, GenericView): +class ObjectImportView(GetReturnURLMixin, BaseObjectView): """ Import a single object (YAML or JSON format). @@ -291,7 +280,7 @@ class ObjectImportView(GetReturnURLMixin, GenericView): }) -class ObjectEditView(GetReturnURLMixin, GenericView): +class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ Create or edit a single object. @@ -362,6 +351,7 @@ class ObjectEditView(GetReturnURLMixin, GenericView): 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) def post(self, request, *args, **kwargs): @@ -435,10 +425,11 @@ class ObjectEditView(GetReturnURLMixin, GenericView): 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) -class ObjectDeleteView(GetReturnURLMixin, GenericView): +class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ Delete a single object. """ @@ -481,6 +472,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'form_url': form_url, + **self.get_extra_context(request, obj), }) return render(request, self.template_name, { @@ -488,6 +480,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) def post(self, request, *args, **kwargs): @@ -529,6 +522,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) @@ -536,7 +530,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): # Device/VirtualMachine components # -class ComponentCreateView(GetReturnURLMixin, GenericView): +class ComponentCreateView(GetReturnURLMixin, BaseObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ From a74ed33b0ed53eddddad615835adc42534d246cc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 14:41:37 -0500 Subject: [PATCH 087/104] Move get_object() to BaseObjectView --- docs/plugins/development/generic-views.md | 1 - mkdocs.yml | 1 + netbox/netbox/views/generic/base.py | 12 ++ netbox/netbox/views/generic/bulk_views.py | 128 +++++++++++--------- netbox/netbox/views/generic/object_views.py | 70 ++++++----- 5 files changed, 118 insertions(+), 94 deletions(-) diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md index d8ad0e7a4..1a444ca2c 100644 --- a/docs/plugins/development/generic-views.md +++ b/docs/plugins/development/generic-views.md @@ -71,7 +71,6 @@ Below is the class definition for NetBox's BaseMultiObjectView. The attributes a selection: members: - get_table - - export_yaml - export_table - export_template rendering: diff --git a/mkdocs.yml b/mkdocs.yml index c36d3f467..dbd31cb50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ plugins: - django.setup() rendering: heading_level: 3 + members_order: source show_root_heading: true show_root_full_path: false show_root_toc_entry: false diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 7d7c305dd..3ad3bcf67 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -1,3 +1,4 @@ +from django.shortcuts import get_object_or_404 from django.views.generic import View from utilities.views import ObjectPermissionRequiredMixin @@ -14,6 +15,15 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View): queryset = None template_name = None + def get_object(self, **kwargs): + """ + Return the object being viewed or modified. The object is identified by an arbitrary set of keyword arguments + gleaned from the URL, which are passed to `get_object_or_404()`. (Typically, only a primary key is needed.) + + If no matching object is found, return a 404 response. + """ + return get_object_or_404(self.queryset, **kwargs) + def get_extra_context(self, request, instance): """ Return any additional context data to include when rendering the template. @@ -31,9 +41,11 @@ class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): Attributes: queryset: Django QuerySet from which the object(s) will be fetched + table: The django-tables2 Table class used to render the objects list template_name: The name of the HTML template file to render """ queryset = None + table = None template_name = None def get_extra_context(self, request): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 3025818fa..5286de314 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -43,13 +43,11 @@ class ObjectListView(BaseMultiObjectView): Attributes: filterset: A django-filter FilterSet that is applied to the queryset filterset_form: The form class used to render filter options - table: The django-tables2 Table used to render the objects list action_buttons: A list of buttons to include at the top of the page """ template_name = 'generic/object_list.html' filterset = None filterset_form = None - table = None action_buttons = ('add', 'import', 'export') def get_required_permission(self): @@ -70,6 +68,61 @@ class ObjectListView(BaseMultiObjectView): return table + # + # Export methods + # + + def export_yaml(self): + """ + Export the queryset of objects as concatenated YAML documents. + """ + yaml_data = [obj.to_yaml() for obj in self.queryset] + + return '---\n'.join(yaml_data) + + def export_table(self, table, columns=None, filename=None): + """ + Export all table data in CSV format. + + Args: + table: The Table instance to export + columns: A list of specific columns to include. If None, all columns will be exported. + filename: The name of the file attachment sent to the client. If None, will be determined automatically + from the queryset model name. + """ + exclude_columns = {'pk', 'actions'} + if columns: + all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] + exclude_columns.update({ + col for col in all_columns if col not in columns + }) + exporter = TableExport( + export_format=TableExport.CSV, + table=table, + exclude_columns=exclude_columns + ) + return exporter.response( + filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' + ) + + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + + Args: + template: ExportTemplate instance + request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + # + # Request handlers + # + def get(self, request): """ GET request handler. @@ -135,57 +188,6 @@ class ObjectListView(BaseMultiObjectView): return render(request, self.template_name, context) - # - # Export methods - # - - def export_yaml(self): - """ - Export the queryset of objects as concatenated YAML documents. - """ - yaml_data = [obj.to_yaml() for obj in self.queryset] - - return '---\n'.join(yaml_data) - - def export_table(self, table, columns=None, filename=None): - """ - Export all table data in CSV format. - - Args: - table: The Table instance to export - columns: A list of specific columns to include. If None, all columns will be exported. - filename: The name of the file attachment sent to the client. If None, will be determined automatically - from the queryset model name. - """ - exclude_columns = {'pk', 'actions'} - if columns: - all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] - exclude_columns.update({ - col for col in all_columns if col not in columns - }) - exporter = TableExport( - export_format=TableExport.CSV, - table=table, - exclude_columns=exclude_columns - ) - return exporter.response( - filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' - ) - - def export_template(self, template, request): - """ - Render an ExportTemplate using the current queryset. - - Args: - template: ExportTemplate instance - request: The current request - """ - try: - return template.render_to_response(self.queryset) - except Exception as e: - messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") - return redirect(request.path) - class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): """ @@ -227,6 +229,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): return new_objects + # + # Request handlers + # + def get(self, request): # Set initial values for visible form fields from query args initial = {} @@ -297,12 +303,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): Attributes: model_form: The form used to create each imported object - table: The django-tables2 Table used to render the list of imported objects widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ template_name = 'generic/object_bulk_import.html' model_form = None - table = None widget_attrs = {} def _import_form(self, *args, **kwargs): @@ -361,6 +365,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + # + # Request handlers + # + def get(self, request): return render(request, self.template_name, { @@ -427,12 +435,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): Attributes: filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being edited form: The form class used to edit objects in bulk """ template_name = 'generic/object_bulk_edit.html' filterset = None - table = None form = None def get_required_permission(self): @@ -495,6 +501,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): return updated_objects + # + # Request handlers + # + def get(self, request): return redirect(self.get_return_url(request)) @@ -692,6 +702,10 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): return BulkDeleteForm + # + # Request handlers + # + def get(self, request): return redirect(self.get_return_url(request)) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index c681767c2..09a102442 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -6,7 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError from django.forms.widgets import HiddenInput -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url @@ -42,13 +42,6 @@ class ObjectView(BaseObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') - def get_object(self, **kwargs): - """ - Return the object being viewed, identified by the keyword arguments passed. If no matching object is found, - raise a 404 error. - """ - return get_object_or_404(self.queryset, **kwargs) - def get_template_name(self): """ Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset @@ -59,6 +52,10 @@ class ObjectView(BaseObjectView): model_opts = self.queryset.model._meta return f'{model_opts.app_label}/{model_opts.model_name}.html' + # + # Request handlers + # + def get(self, request, **kwargs): """ GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. @@ -105,6 +102,10 @@ class ObjectChildrenView(ObjectView): """ return queryset + # + # Request handlers + # + def get(self, request, *args, **kwargs): """ GET handler for rendering child objects. @@ -202,6 +203,10 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): return obj + # + # Request handlers + # + def get(self, request): form = ImportForm() @@ -303,21 +308,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): def get_object(self, **kwargs): """ - Return an instance for editing. If a PK has been specified, this will be an existing object. - - Args: - kwargs: URL path kwargs + Return an object for editing. If no keyword arguments have been specified, this will be a new instance. """ - if 'pk' in kwargs: - obj = get_object_or_404(self.queryset, **kwargs) - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - return obj - - return self.queryset.model() + if not kwargs: + # We're creating a new object + return self.queryset.model() + return super().get_object(**kwargs) def alter_object(self, obj, request, url_args, url_kwargs): """ @@ -332,6 +328,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ return obj + # + # Request handlers + # + def get(self, request, *args, **kwargs): """ GET request handler. @@ -363,6 +363,11 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) + + # Take a snapshot for change logging (if editing an existing object) + if obj.pk and hasattr(obj, 'snapshot'): + obj.snapshot() + obj = self.alter_object(obj, request, args, kwargs) form = self.model_form( @@ -438,20 +443,9 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') - def get_object(self, **kwargs): - """ - Return an instance for deletion. If a PK has been specified, this will be an existing object. - - Args: - kwargs: URL path kwargs - """ - obj = get_object_or_404(self.queryset, **kwargs) - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - return obj + # + # Request handlers + # def get(self, request, *args, **kwargs): """ @@ -494,6 +488,10 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): obj = self.get_object(**kwargs) form = ConfirmationForm(request.POST) + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + if form.is_valid(): logger.debug("Form validation was successful") From 1c946250424e849dadcf91bef5db7cc17b222518 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 14:48:27 -0500 Subject: [PATCH 088/104] Remove widget_attrs from BulkImportView --- netbox/netbox/views/generic/bulk_views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 5286de314..82e1dc217 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -303,18 +303,15 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): Attributes: model_form: The form used to create each imported object - widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ template_name = 'generic/object_bulk_import.html' model_form = None - widget_attrs = {} def _import_form(self, *args, **kwargs): class ImportForm(BootstrapMixin, Form): csv = CSVDataField( - from_form=self.model_form, - widget=Textarea(attrs=self.widget_attrs) + from_form=self.model_form ) csv_file = CSVFileField( label="CSV file", From 5abfe821bc50084a5bff4a3f29a7168dd844b326 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 15:43:53 -0500 Subject: [PATCH 089/104] Changelog cnad cleanup for #7853 --- docs/release-notes/version-3.2.md | 3 ++- netbox/dcim/forms/bulk_import.py | 3 +-- netbox/dcim/models/device_components.py | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 432d6fafc..c35806c04 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -69,6 +69,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs +* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to interface model * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group * [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links @@ -100,7 +101,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * dcim.FrontPort * Added `module` field * dcim.Interface - * Added `module` and `vrf` fields + * Added `module`, `speed`, `duplex`, and `vrf` fields * dcim.InventoryItem * Added `component_type`, `component_id`, and `role` fields * Added read-only `component` field diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index acce43be0..1aec329eb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -620,8 +620,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): ) duplex = CSVChoiceField( choices=InterfaceDuplexChoices, - required=False, - help_text='Duplex' + required=False ) mode = CSVChoiceField( choices=InterfaceModeChoices, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index cae0d1150..4a68f7c8d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -546,12 +546,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo help_text='This interface is used only for out-of-band management' ) speed = models.PositiveIntegerField( - verbose_name='Speed', blank=True, null=True ) duplex = models.CharField( - verbose_name='Duplex', max_length=50, blank=True, null=True, From 571e9801f3c42193e8e26970d63fc0bf9933c774 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 24 Jan 2022 16:02:54 -0500 Subject: [PATCH 090/104] Closes #8195: Ensure all GenericForeignKey ID fields employ PositiveBigIntegerField --- .../migrations/0033_gfk_bigidfield.py | 18 +++++ netbox/dcim/migrations/0151_gfk_bigidfield.py | 73 +++++++++++++++++++ netbox/dcim/models/cables.py | 8 +- netbox/dcim/models/device_components.py | 2 +- .../extras/migrations/0071_gfk_bigidfield.py | 33 +++++++++ netbox/extras/models/change_logging.py | 4 +- netbox/extras/models/models.py | 4 +- netbox/ipam/migrations/0056_gfk_bigidfield.py | 23 ++++++ netbox/ipam/models/fhrp.py | 2 +- netbox/ipam/models/ip.py | 2 +- .../tenancy/migrations/0005_gfk_bigidfield.py | 18 +++++ netbox/tenancy/models/contacts.py | 2 +- 12 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 netbox/circuits/migrations/0033_gfk_bigidfield.py create mode 100644 netbox/dcim/migrations/0151_gfk_bigidfield.py create mode 100644 netbox/extras/migrations/0071_gfk_bigidfield.py create mode 100644 netbox/ipam/migrations/0056_gfk_bigidfield.py create mode 100644 netbox/tenancy/migrations/0005_gfk_bigidfield.py diff --git a/netbox/circuits/migrations/0033_gfk_bigidfield.py b/netbox/circuits/migrations/0033_gfk_bigidfield.py new file mode 100644 index 000000000..970617a88 --- /dev/null +++ b/netbox/circuits/migrations/0033_gfk_bigidfield.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0032_provider_service_id'), + ] + + operations = [ + migrations.AlterField( + model_name='circuittermination', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0151_gfk_bigidfield.py b/netbox/dcim/migrations/0151_gfk_bigidfield.py new file mode 100644 index 000000000..733e6ecd5 --- /dev/null +++ b/netbox/dcim/migrations/0151_gfk_bigidfield.py @@ -0,0 +1,73 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0150_interface_speed_duplex'), + ] + + operations = [ + migrations.AlterField( + model_name='cable', + name='termination_a_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cable', + name='termination_b_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cablepath', + name='destination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cablepath', + name='origin_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='consoleport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 18bf65895..e3cc20177 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -38,7 +38,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_a_id = models.PositiveIntegerField() + termination_a_id = models.PositiveBigIntegerField() termination_a = GenericForeignKey( ct_field='termination_a_type', fk_field='termination_a_id' @@ -49,7 +49,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_b_id = models.PositiveIntegerField() + termination_b_id = models.PositiveBigIntegerField() termination_b = GenericForeignKey( ct_field='termination_b_type', fk_field='termination_b_id' @@ -327,7 +327,7 @@ class CablePath(BigIDModel): on_delete=models.CASCADE, related_name='+' ) - origin_id = models.PositiveIntegerField() + origin_id = models.PositiveBigIntegerField() origin = GenericForeignKey( ct_field='origin_type', fk_field='origin_id' @@ -339,7 +339,7 @@ class CablePath(BigIDModel): blank=True, null=True ) - destination_id = models.PositiveIntegerField( + destination_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4a68f7c8d..9071dfe46 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -130,7 +130,7 @@ class LinkTermination(models.Model): blank=True, null=True ) - _link_peer_id = models.PositiveIntegerField( + _link_peer_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/extras/migrations/0071_gfk_bigidfield.py b/netbox/extras/migrations/0071_gfk_bigidfield.py new file mode 100644 index 000000000..64ce3c471 --- /dev/null +++ b/netbox/extras/migrations/0071_gfk_bigidfield.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0070_customlink_enabled'), + ] + + operations = [ + migrations.AlterField( + model_name='imageattachment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='journalentry', + name='assigned_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='changed_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='related_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 8dfeb2f18..4e703833a 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -43,7 +43,7 @@ class ObjectChange(BigIDModel): on_delete=models.PROTECT, related_name='+' ) - changed_object_id = models.PositiveIntegerField() + changed_object_id = models.PositiveBigIntegerField() changed_object = GenericForeignKey( ct_field='changed_object_type', fk_field='changed_object_id' @@ -55,7 +55,7 @@ class ObjectChange(BigIDModel): blank=True, null=True ) - related_object_id = models.PositiveIntegerField( + related_object_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 7189aed03..143bc7d9b 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -351,7 +351,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() + object_id = models.PositiveBigIntegerField() parent = GenericForeignKey( ct_field='content_type', fk_field='object_id' @@ -431,7 +431,7 @@ class JournalEntry(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - assigned_object_id = models.PositiveIntegerField() + assigned_object_id = models.PositiveBigIntegerField() assigned_object = GenericForeignKey( ct_field='assigned_object_type', fk_field='assigned_object_id' diff --git a/netbox/ipam/migrations/0056_gfk_bigidfield.py b/netbox/ipam/migrations/0056_gfk_bigidfield.py new file mode 100644 index 000000000..f40f65271 --- /dev/null +++ b/netbox/ipam/migrations/0056_gfk_bigidfield.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0055_servicetemplate'), + ] + + operations = [ + migrations.AlterField( + model_name='fhrpgroupassignment', + name='interface_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index a0e575e45..f0e3c2a23 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -74,7 +74,7 @@ class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - interface_id = models.PositiveIntegerField() + interface_id = models.PositiveBigIntegerField() interface = GenericForeignKey( ct_field='interface_type', fk_field='interface_id' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 44dd84525..632d71034 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -801,7 +801,7 @@ class IPAddress(PrimaryModel): blank=True, null=True ) - assigned_object_id = models.PositiveIntegerField( + assigned_object_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/tenancy/migrations/0005_gfk_bigidfield.py b/netbox/tenancy/migrations/0005_gfk_bigidfield.py new file mode 100644 index 000000000..12bbde295 --- /dev/null +++ b/netbox/tenancy/migrations/0005_gfk_bigidfield.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ] + + operations = [ + migrations.AlterField( + model_name='contactassignment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index ecc599021..cacd682cb 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -131,7 +131,7 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() + object_id = models.PositiveBigIntegerField() object = GenericForeignKey( ct_field='content_type', fk_field='object_id' From 497afcc1e451803f6ef741891ccecf71173b3c4e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 13:53:31 -0500 Subject: [PATCH 091/104] Rearrange plugins documentation --- docs/media/plugins/plugin_admin_ui.png | Bin 23831 -> 0 bytes docs/plugins/development.md | 433 ------------------- docs/plugins/development/background-tasks.md | 27 ++ docs/plugins/development/generic-views.md | 96 ---- docs/plugins/development/index.md | 145 ++++++- docs/plugins/development/model-features.md | 64 --- docs/plugins/development/models.md | 107 +++++ docs/plugins/development/rest-api.md | 46 ++ docs/plugins/development/views.md | 254 +++++++++++ mkdocs.yml | 9 +- 10 files changed, 583 insertions(+), 598 deletions(-) delete mode 100644 docs/media/plugins/plugin_admin_ui.png delete mode 100644 docs/plugins/development.md create mode 100644 docs/plugins/development/background-tasks.md delete mode 100644 docs/plugins/development/generic-views.md delete mode 100644 docs/plugins/development/model-features.md create mode 100644 docs/plugins/development/models.md create mode 100644 docs/plugins/development/rest-api.md create mode 100644 docs/plugins/development/views.md diff --git a/docs/media/plugins/plugin_admin_ui.png b/docs/media/plugins/plugin_admin_ui.png deleted file mode 100644 index 44802c5fca3eb41f7f84cfe50f25de6ca0cf55e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23831 zcmeFZcT`hf*De}BKm|lmssa&3q)Q1%M^TD2>AgrV0#YKqiGoTCB_JK?B!NhmE+Q@T z5<&;*5Q@~$IU9fP_nr5ianC>Ze&?Jq?znp}Mpky#US-a?p83qR=H}B&H3h1xOjki5 z5S5bRGffbP1PlTZHC-kH?!*wLZ9pI}Na@)VZLid|v~?{lLe?gs_SAMl=6Az#%6iJ2 zN2_?5x`ex0?^?Cgae7R45l34d^Puzh$wgheCi2g!8f%%fWw-f0TRgXarBx7_qWC2E z-Ahe@cCB16(R*3p_PCvT{#aR-yO~Nu0n$Plh4&2{Plqw@&KmH9-P%N%#}A%eT)(sr zYCY&dH!iL~2^!h!7X<<9|GkBPxdvxnqlT$^j2{{1-ACZ!Z&0EHhv|$p$Tbub;;REs zdp8SxlpFSW>JNXloof3!9ed0#=L<|v83Q#vw(!R?aPk_cME- z>o7F8mT&qSvqB1>EkFN!`8%wuksK2^M~;1mZ?;u@EWCcDg6lE`AxmADE@dpCvb8>` z$}F-c%`IIbHT3w(g(#p8WP&Ky`ev%CeVo#IW$Hn*=+s&+OTGF1;k8(_Bl;SPRA&8M zoAeQMw$3I7TPv5oPq=wib;a$tp8yg3Cf5*bqHcUe-2`;_OyN%L!V#YW8qlz*L^`J} zjsL-++5Z>VB|&5Ohgq&doaQq68NL zrx1S*MHKr8FQ;b6XKM5YcMNE|HA!lFuK}6LVEM$9yIo_&(^q)83+7^8!LwNRaYOc) zdf5VZEmC5?jb#T$_-g3m*s=>ME&(G&G3k_tKAsMLj%vB2G`?vr;-Uiz z!movp`3lbCyZaQ6C2H&>?#%g~X)6Sb-0k&b4Xe|ASSBUM;u}>N__BaGzf{}vlsA&F z0n7JHG>#%bzk%gDM*)q}Y}FrC*hTO8Ln@C5zxjJvusVx&VPYpI7Lg=ZZ)>?fRxS>% z@+i)HiDsRwGUQX}S*`Z>cu9_&0_3FTP4*ULoZ&wFaYuc5>O_BkirKZH6Bx2Z#<}mu z$7oT@553I>ZhITL_DuryC;ddsh29E$OTc4-!c!Fr&?UDFuRGTOh0NNhDuKKYLo9tx zf7LQEj^x(dEUC3b9F$I`3eayx9=vlxV=ou z?>bsM;sYd#pWXU{nR8T_#?AatgUIV}QFzRUrEOQ(I`oE;)zL36??u2iUVXrvyiJtE zbvtT5q=>hiW;fe3azKso@AQX^x$jys7y9Y(EwPxK3||)tI;fc%jB)oB9ylw0;lxVb z+TmB=F0&TvVxG9Fc5As?$i?wjwZe32r^aGG$(XOCfP)rg!fH1Rx%gw%Ke4Z#kw3@}(Um;8O}VD25NRN$KT=nQH5t9Gn^^;FOI3Ya70Ch)%z_kl0Tpw8z0;EqhXt-} z#yOfN@`dVvZ&HG998WZyYt?(7$oUV}2%Xp;lDF+{lhY-wq#-X=`c_Hnb{KM_H=plhIZKwf6e>O@tpK}K$(MQDrj4GB51c}-RY1_aL9|R z=NEDCqc_Fi-MtE8MK4(jf0>(Sp|M_(p{1-%nJv|;RdVP7iUgT%Qt8?RtNHAzt zlHO4lL2NzNC8$6eH{*F$vg+dr0GqeJ2!m`Fxkvv>WBzQV?uAE?4PE;>{XBCmWNV{D zvP_)pEn?%KbwlRDW#_+gAZaH*gWQoPd)pDfb<*+L15~n{c~9=%jqxyF+3CkZL$A0m zd-Dc^Xv$;)%%x!a1gogE0gE$zsMHo$HnPE>T4Hf#Z#_Y!+Q$>o?;{IfAg8)7D&Yf^ zQd`~<{%Ya72Jhu#EQ66T|yni3hFtewF~{LT74;N7{(UZT{2QNXjkp3GA>3qE|9)R@1Tx#4)pe=;)*29 z`xW`K2CCnycjY>5`nwGTrlAeT7a!PJD&tZ+mTnwv+dm z4}GQ$JQK>KAhq+3lljb764O8zOlC!hifI0e=~M>NB~?P$*~}!KPdw;OpJ)%W8+JaF z{KG?;{@bh{bn7xan5LetKQ2>9@52N&N^Gf zw!xYz?Hv8Zn#V@3;Eh=-Xx7u$v^Yw8Lq{3U2RNAaIb+Alf^Kpcj_3F!q|fN|m90w? z$5@PBrs($7-PYfyr7|DrU^pjVnq)Sykr%r*@2ok3z<`q%S5*fc&U`VII7<%GOi=(!raG~D}zvFroZ-YZnoMJ9cq&^ODwF66*WzzX-xJG z@7)X3LqLr}*xEEN8OaV`R^vAWT{&l=C|??q9vv5&cW=lJ(I|WFBp7t5?Rdm}_R`P% zK~l$DQmeELA76W-CxCS?wU?8^5@o(U+!>#t)c7Dk{IT)pYVFypp`g7A|EomvA?n1| zy?f_(?vT*ve5DkfVt|RFHtp(gnFGY(Qz+r~!5y`T(d^mdP^W0g`pxA6>&OIfxItw7 zjk#C*O9dsOmffEhDVSy7kr6i^0DeE3dh|6&?5IJWj2Qp=m*nCg6UlZ%BdKGW09auz zg6QsrY-Y>sm`K_M0`*YE%uJqs1*yr)2H&Q>t;g7!6Uf?BBqQmr2hQkEhCLVC*Vj4= zb-tNO%5Cwg=>h~hU}f9i&Y?R`;FY$hp3fD<83o@V@(W^^r%8?DjA!>H`yic`y$0Fu zSZ$eMOI$&IT<+cD4uuho1#?p__t4!XvQ^(zFTnt`^sG3Z`4Wg(%zs2?1*Jnl=#Y|Y zE@|QEq~U)Jtv7Fl3e0+m9^cvbQRrT)IQZ;EeZG{Cg`sidI7OF39m}C<=1-RVfyw|- ziJdz9?sdPXre+4@^X3tlQuZEkFsXoTyZAOpQIxK%{sjr`oUcQQbw2e9A+tsA z?W62i7gjB51F!pdgpV2$7RT^Gem5&Q{i0Vh?Z|>GfiF>XF?E`FERE{f`yUg6+nuGcm@_26yDqNBQ+=p9O-*ybv8cnBu)bL)5te7!3*F~kb|Dah!jYx$fmnf8~7)7T!m#y8$1pHiAyqU*-h^uHuYD9G84IW5)@IE3)eontjS< zG6mJwwM_78hdxuEOf{sgUzS|oTxs6|sLAf1)h|&H?yV1FMzp2khg&2V$(U>)CTPc& zY4srd5%_I%^Nk9V319ajcpudTM^KJ5Sj;Bh%UcK%EHZFd!@znEi?7M2(-U0+Xn)45 zj9`%4>)hOX&Yi!`nlXhF$f$i2rp?K_W}AG~0VBxSo1`VRd22G67Hu2w-cYrS+kwn5 z%18-MiA%@cAb9S-@F7n^U~y|*b>vAglHamM^lq2EMO zQPd%MvxKFN)m&}=N26EZO5d_mqKs-Qo*ja+DsLdTc#u`seHFJ0Y9Bx?1st^%}TW`2NV0p7L=q+;P>T z4xQabQGQAi-l(kK-PXTEOPGqFmp#F)MBY5!?WV4U48+}djFu|ZTaOm1{g(_(;u!vk zNN+T=Uo1obrZD&aj}}hS4&B242FW+H?*3I25TQNzUskY*C04AuGu6g)x?(fr+vdAe zzFdv$O%IM!ACeQbt@tj946nF7u@9_I>0})pe?bgXd7UB}ikDv_l%Xdy;(XPntnFRB zpJZoeb0J++2O%d0s?@B=s?#>r35BbcuCGiz0~v?5l7AI9%cJc(n@J}fChf#T&2{pR`6BlCe6tTD%M!FiEGK)2l0UQ32-u!uo5|65hxmu>8~Y zJ#D{I5Dkdr(W66*mR)WSDSrr5^=Qmwb8f#XR7n$n(jVEAYkZI z#)ji=za=jpzn#Ty@r(4CLf0mBQ(iSH#0m<;r6tvg5&om=2;a4$%B5;sw*2YIEwZ3JCj?G-m2Pws=V>Zixe zIs78fQJSj~AcW-x2WPeAjeCKk@`2Si?PC70;t#rKJRhNJLkJ#t4LeYEVX1WV13DJv zPUYk`(Z!}^&o;8ro6E!7v12K0Xt~5y$!I9SrhVx)$Gl0proo@3BU6ZKm!s=p$zq)r z0-rW8aj#14naN-ZVoP#(aw-8_^2u`SV%|pK+Nvt8@w!gx>eeObk#6yCYRw7ibp;+{Mj5WB5A%J*FiSsg~u!H(p<hq z`lfs5vbJ4d4dd44jPwzJMf&E_WhL!~>d( z`WM)tlYhce*7B}+CoVzpi@eN@+hsijZwN=xCD^-^GDAP%TtsLJ*LOFgs86|%iJiSd-sbX-i>Olj!$%SpfI~xOAtxup*@wY^S6b=6%a`$Zg-!3FLOi*Zc4cF@W z`}oV|P8XRdo&UiEF*y^E-w@AP$MnwE0~<~9Q=K7uQ#yxOHD=WMKw*ytM9hVJqkkDR zFeHV>kNjH zC!?Da*g~ej!)D_4s(M7@4zkCrzOXJiMSNHs>Lmy1DL`)c73G=LcTzam>-(R3P1nscQ`tYME-L=ew(8&z(}?Vib)GyBun1&H zi$=V!D*U#thdp^CG|%brK3F%}{QF_o;mh!JGu@0`atv4&m&Nl|bz1y(2_5lIEQG zD2_Jb^_tV3FeA4!jI=2falJ=g3sbJE+i3ziV|tq{b*-;VD9FB`$KhtjmhJT{6K2RP z;&wO@WcONe)fK#%9hS|4vK<~U>2sj(K4nlrxQ1ABsB`5k30X@eG$4vA zTff`jFTrjTD!xCup}9e4T~*91`?DvziFdP9VQEhL*G?cqLVa^J(^a;O3e1%6kpugi z0}>@UUoh+vv7(dZ@v6w4f9W*iz?K(L&dw67IBXJ8CxqCUE@Bbtb@hY<89VEw*XzN1 zZa{XUR>slNt@@!S8Or!*QFM!iEJI|9+@u|r-5lwsQp50pk z6KjuYTSH_fu<-qj5p@*F!W-exs+ss*sUeGRJEU#d5yN4+O$$4wp~mWol+tq!V=gIhfUkn(d>NCh)8 z;JA#-ce8L{D$j}>79vB5A5)jYPy{rra`@BcQ|L~?S>Ph?JycO=2GbLaI^Hz-9P&Wa zF&$;k`aYn4oz>3dZHo*;jlF}C`^Fje$@1;BN{pK54a#O`^Zg#E;ATkG zZe*GI2iKpU0#lzV5|EjC8xF!F;;<34eli1+*S2Cls)Rtzt_%^_TW@=`UD8FO}| zz}zUtzKH?v>cS~|9Q%FE=x;kCG8>GR0+T4N=zf}1Sp0%R>9B`T81B}PgmfKaXT5of zD4!D0D}ths8o|q_!^t6iH>toyN99ocY2ntD7yEm?HGWH`?OxH?^{9$zEx9nQQKf6( z(fucoi9?+w^jprx2BUm&d5@y$qE5Z>Ta2=$>m&}}g-~wCstee1tViET6%I{3-Y#!OVjx;>9;Mo>26y9h$fo(@lSWK`{j=7tkBLeSaW^<(G<1Uc`-5Zv#E)u-I;jwXaoUX0UsZ)J(PU!5As>IrrcnvOH>*Q*4pZ6pT;b%yVVd+}!6?(H^sta8P->v9FP;7KLHF zFyW0ZufNvs!9T-O`|8cyz-Xh)>eP!UbR}h+83rn*`WVr=rx#9cgL35n*TF)voqg5i zB#YZ~!eRAPClNiZX}^T7SzCVTL5~uleN3jC_uJ{9Xhh zw(aM#xz2!(zKCZmO8+;6Gbd@li6d}nULv~mPl4?J69xHLlJ*GWs4^~$P^Xn}ySZ>j zs$Ndn+x!RgqP5ClJo4dpgw!iCds&*H7@>ugfj3N@f9j0!!B>;YUc2Si-nkIPH@*%E zaJXfuk&58L==_^&jsr_^y`tFydSyL}DOMQt;18Rpil4b!ipvZz;wi0MMZN64A58en zjy}WwRBZ4h2pq=_B)~6&FWTy*QlbFIT?>yGr7e+bo&C1D9f6tZnme6-0rI8;2VGS8 zq4hwC$VwxPT|SAMY&PI0PWOIxwZ$AQ{-gWWXuaf}mthbU^X>^Y`K5M%xbW7VUll;)m$r#Z3I z`VqrV;l*)C^>7ED8pc2`>tv0Gk&28_)d0hdcsZ{BtDl_9N+bjOV>ZGV%8n6Du@Yvm z##Ao99`{)iKE zOtk)_CF~%-n>1dGZbJWZqb_?%Z7XjS z1dn1r5G&hOUY$?9vlHl&7Q=eYk9_e5^wW20B0##P+Oc%9bmUy|PQu6>F?rHQba75x zA}Kn?OG6`FS#BuNtU>pHrhD_o)*}T=LeTuvlMSnYVn>=XHn51y1y@ySNb*^SVfr z7Mtwt)lgAAokpoL(7$p4rZ}94l3we&CMbD}gc--3;iVSc+7oOwbv1Ts?pnEy6#Ho< zd|nm5*Ps{269p@}&>w9_LLbyg%T-r*#$B41j}Id%mT6;@Ub>Bc+S9};yX|Ca-N79* zDTZRqP#3aHtFaR3&>p(OLid2V^ot6HdJQKkR2W=&)nI!Eo z+PROnoZ42mtuR%NJli1+YP7aVv}2Y$zd?eFFH06;jWzNs3wmhcwpX7KfZ z+;0S1XK(tqIoL08S`-hzJ?NHBYmEv`$% z)`R;?Z*G}Q9FOCMmnNX6Q4Ji|pZeq%LyUfwBsgeZ_k+$+eO`AG#3Nh#y=-X+J)>1S zm#&eO00%yv{;i4MD$iEfNc$<@HK3=vl0hnCJJwNKCLOrK*pJ88ff>&{zjufaI%plT zmqBLL0SiYyX-C0K4|fma?=qWj$ov)-mwe_f^cpxv%3sk6S>m8pR`j zvKc9=?$}2VF%~@Pv~mw^0GAc&mwbL?-UqvZpLST9zX9cK@6N-ee)buvz=pd*vbY`h;UIX zs8hc#ODCcH)5w&K5tyDGxBL+2sFh$wWTXGX+ttOf^S8Ceb7>5=yD)-DR)S=`(*;@Z zTQ`^A(Pmq#x~;s{RdnpgG4a;GJyv`M+JbaiJZfjy41h4j(@Jyz^8wXe&#`6qm=0Rq z)~iJwjD5E1Duy}Q$P2mdwyeB^|EUz}F8$@7#iBY(WTolUVq?9Kp;PAgx+3-SAaU$I zs5E$6S_x#&9V>w5tEn|=zc`mzmR6?8FmSww!!E`1j_0h=)TUYr-8HFv#|uum3~o-0 zcjAeEGuBXE6VkEIDZp7`gUs9clA$k&K9;5NJUc?O}20WI-qrdVr zB3~0Jd5pJ+&~khwwd%LmN*H}$8}VYUj6qgJdx~S_-2zVxr*EWvLfbH+T-j|j9{!#V z7JRi#rKHtNQVDd$c~DZV;hmEUQ=)5BWQ+?P%36<=igy zz2u*mNMe^Og9X5@4s~&AwPT@rj<8Q;u#y@{#_(~Ap4|oUl)Wxe7!{nr8A0^PATUvQ z@U>Frhx=0{w)1YnofXMkGA9^?>Mp0Qj*_Cfgl4wlSl2u)A)4 za&NG%aT;XV8U~|uqia2oHu4XpkB9S=TfEPX+w5Y+0wdvlmakc*D2B5hNN{V%uV>JW zeSzm^m0hDcpo6^WU{f~vlQLxvx)Q&KVwZShQWmf0r<7(%QWMWC7(R{(<5)nIeLxwq zIzrw8QwySx-tBrv`p4*1f1}N1A^gR35AFtmB&lpdA=k4F+Lp>Wxi_9BU6@Iu6u|1# zi82Nsxa&4>jgg&9cGCTK0wFX<3)GPX@}que{S=+)8_j}kae24Y42dziV=RH6f~L7H zU3d`?NRjdXFZ|yN0d~LS+qcZKvjC{4C$hetc`K=`w$|wM^wdya-z#t9_a256kYBIp zCD{FMK*gROwaDmbtxxaDXWmA$FH1q+zLlTMZ}J^kf7`jVWAEy!5gs0HXlOVvFc6!N zn5gG>a~7Hg zU|?Xp{QUW~41aDv=m%+PYGw)9_7v3CO1wS>emeA%mh#!P>%LCI{qerV@_xzBwrqvy z-I=D40yh9kf9y~b-M>;^Rh3_R`Oj_Jdqg=oIrmr7om^aS6BDT{Qay$o>#N1t}40Cxa&?BSEt}hqIxuJ}?b`#4jO_+P>50q0||@ZJRVe z`xKXf_Fvk+^Vkt4Zf_2wwhV<;yi?4QCyUnqJ@>mg%>LLUnRQ*;-?|#Z%>+pvxOeP< z#2{9U8sb?X@f`_Q4c6uCC8Pm=dmt%9^lQ+dP)npXG=XN?CSV}9r$LESG-(Z`ZQ!4= z7^nI3*WDo58gE0Q`@-*kQLr`o1!`}e9&?Xv4=9h7?&(;cU=g_O?){gBmagh zi=p#`0tg%$0743u?cmnWr>Ad(&by^G|K*n3SGqD%_V;qU2V@oTc0JwT}T@rrkj3r7;2<#r11cWMU-NEptU6D68mqF7{j7P=UT2#l(;po9<8=ua?N2l{N#_#0bocXI^`;%#_w(5<4nSL@Pkv* z5WU1sn+lAsX)c*wX2lO*WvmENElr#q*`2q&$iVziM=;AFy#-3X45@(adNd%57A`<$Lf9^OL%p7>)2&3`FM*8 zZ5$5F7c!J^Lly{#3MEO1tW4$h7D}nRm6NrVtqikJo{BH*fRt+D}cQFmR4^K+hx91`xJduc! zi9AGt#$iIvVTTcdmz&~*YM_P}tSq-znxKO~lp0%GFCa=iJDIZ!U)hgrxWrsJS+CJ? zG&&q#aK|E06UEY-vBI0Ww$MkCAIM=9(l|Y{JU8Er6Dnyrhk|c~!wb`V+b(m4$FA5h zZQVfSxE*D~?Q4Z4KVN`|KtVaNXP{a4!pba-?BJsYf^G#@@?@~6)GDeRAyQ8En|YWw z(P+m|BItWcr!|E=Rg9mTj*$*_-Nc^AV6w<4azO<0%SXHB$}n13XC-o)oI9iucROQk z`26zO$#K!q^7)zisq{;YlhgAqJ*R->-gCXMs84WauNl{V$;c^uf=LD)GSSwE)RpW4RqE&Q-ZM?L{|1Ad`1QbGj8g(xu}ZZ zElWHpOerxrr4}N&0{3)N4dK~arD{aK@&vh?9%;=GGB6}7Um+}lwu ziG?c^U`M|mPi5>d7c@+m{&<3Kk|~l_c6g+>PPt z%1A3#wGHIJun0s6N`=Zb@2dfK5GB^AxzvxmYTGrIqcBtB5#nQ&cW?`hmj%ceIVO_L(4Zm7Fq{r=OoqrOUi3dV}nvrgc{Q!GQY+ z0$ggxgKi+o;)T;Xur3^K|J=Q zXe0l)yO8q^I-Or%np`^YF>Ul-t{vIZzSXZQ{F4(E(%>T4w$wNGgo999Vj0{BQmYOL zPET{1s6UO7j%&6J>J>MR0gecgEmd*^8MuieB(vDShZ!%&989)3ACcvhj-JoI=|FxB z-G(PQ`4u}Da2wr1QM`{Pve(Eh((FE?T3~i~`Ju?L>YXK!5C=9Tz$VlmU7;enG|)ZB z@IK)D>=b^ThUwYU=JsEEaz{9sld{sDiN3yhlnqCi$6S}G({Xc(xX?T`XN7N-{rD7x zn_7><+ucyLhC}tErRY1!A1^aE&D@}5C2USl5Ao>x1F2E)wscZ%U}!bInme%xl6+Vf zbIOnTWtx=;we#=MKV07hqS<6@sm|61>f~>8>pqPNd~FmGDyh7E?(d*6SER)IJc7so z?_O+}g1H?Ec)vifxAiE70oD#kWIZ1}ocFo))j2fT6Pf#201N`bj0YHG6NF#$YZ+KZ zoawweP4Q=P-tWC{SphE`2vCmWzQ=uR#b1SSK;ruRc6s!tzS>}QM}Q@CQf3|*+WU@H zBFO6tDMh+(x;!W;HFfCpn4tGg(T?E?)qRp~-K6Ogz0*Nf(*fnck-R{EA+11n%HsF& zNilEJI!?^S_SFOXSkG26_7)Wq_XfSZW-fb0l)f~-}-F^+oAr&bu}9P+`Lp@%Z^92O|cIXZI$}V%v+9A za}G72!+Hf>4)6-p)4jB82#R)VkT7gFkz8+tpVg|l)w=H^J0XCRgLO`*X3WT53cmv6&etyPtb8{L`?h8k} zXJUFd%jgKuII?J3Ns&fnfNAox04!KG_X=P%m%)~nmTt9^z@Nse`+~67!+ocu(KjR) zM@AU_sSEzMx`d6%!I2St{p(k#?v*@M%97u>=W*U-IaTW!J5GY)`6K_QuA>w{-~#3Fzij{iBd3upsQ0kf1)mE*oRoB6#S5AxWNJmyMMT3~ZEF zm_wysh7nI}2vk&56qUb0S_1q(mz|v*`}IpyR9Cm&br`me zNxPlP$;kLkVhLC7!n% z;lY5dziDl*mU)n%TeJoux1^uSDQd*9uhUZlhgdgmh}w+(9f-)k=CgGuv`+L3zvqm} zxde-f6J(^sl)ts(`eUemD9uQzF#pBybmAqnn362}YD%7NRhnBtpE<1QoBD#(-Qzoj zgLgxokTnOGFc?aO-qnuNne6@19Rx_L7g@IIt`D`!P?8VP$UFVO<-%;>Jw56ABxA`2 zV@Fws6V4f}u7>-(aNc`4Q|9a+_zT?J;I`KlS-^(_hit)C<%!#_40-Ou3GaveDFqo+ zFkPp{Smt}2lD2-Ps=lD4w6xLq4oWc4H=NMr;LN8BS{eLsdS)gvcOz_~$w@BY3IV>4 zT653YcsWBaBpk#5EvQo}Dk=()IMOAi29!G+y3VPS*Ocj>fh#P;ex>-3B*@^VBPE<) z-9Ui>wkN%FjHe|Ua36m93o`6?j7g>Fg9I3?xL$ALye%$W*LqcvoP3A-n0Gy0^?dJ# z?N~Ya_2{-T&bZsMl!7dX!9L>%(12F4KUv&W85y?hEm%Xe7aOeFB_kp!cQrk2aAM-) zs$1k4j*Yl|NmVO3Ep6|Za^f-|xPue%@uTVp;5laJbH@#5v4(R~=6mj|l{UbUjB!zC z*W2Zs4zT5`xI7@kolGtd#jGVj(%!J0{v)7PC)plW@@Srj*Mf48MK&)#U(|Y=_@l;$ zh1@gPZ-!4-!BV-$R_)~o%U@rgTDJ4Qo=JRERjwTtk#x2zqg`Y?Ck;=am%jx2!>6zC zE|BNKj6~;NXCR`#SUqB>*z8j>d9!=zsVw;AT4QDdyf?Z45N*XXV#Q*eTm_Xg+=SyL zST(2ERVqQN1O&$@$nfN9L&D!dE{~l z$XoQAO4BrROnX5==(Kv&9`E}^>cWnel}HpTLPJ+Q+U>NRy$hqR%l^3n*h_U_pqthM z(V#B-p7obsIY(anrUsXlkil;67uLD(ttIxYKaB5>M^5<5FX&R+&mZ5@Aey&wJZkj2 z=t$s>Lrvy@Tic8FHxgH^g&ClwduI;xs2P&pK7bExLfg&z&M5qY{Yjs;ol&e(P=Vdc zRyn5|HP1icNIPH9^Qim)lA}91KB*?{f&`aXWu1+$fVCV>C+>%=xMQK7NMN^Ql9}G4 zoq@rFWtgK>>4SVTW(FOzN&TGqX=5Ld3Q!;NWck82F4d$+Cw7=?JFgHNoSZZ#myWF- zSs}`8)!hBDcSHI@LRv^H#oxu@tyua=72@t-GaNGj1J9jIrlJ75C#S5!pk^K*0a{&T z1^mhKm+$t}U}u69uo>g%H<6Y(=T_UMNh7;xbL??f^t!D4$=9DAr4XyW@8i={+=GaX zKIs^DEr+9t`^{g)BfDCzf>oNsfkCMca_;%DriS9cmNvy|!+R*n84pjrFqLNCXSq%G zUQ3I@5(7EFZiJN}lhvd3!MjK~f9|-I_kgXK_kDs;p*51KGw{TNe=LXrz)F?&*Gx-e zW@7c^kleTQn<(n(Nhvi{&pVyHdjZI^ttyV7YM;H&JsC2{+oH+X;o(@AsIxHsSLyv6tdHh&;CWhWYwN(jL*(CV%G!04t7ZK$Y7Ha6|&@b%uJgAGrNgz8D`4QFC0-xAPE@}i>!G=K=6#;jnZ4IR} z)TvpeRTMLN^MUai6q)6G%h_?L^)n5W)XYCxxy3>UJOC!`!Zjj!11s$xPLc*sU$UnH zKYyM;^&z_Nx~8_3w-0HO9kw`iaIA7R@hI^p8v4yuUn@3;9cprDc<%c1>x0cfiHqHP z*=+`$+*h@f@N9fQEQq-9{yNcoQwVvAAScm$`!qD?h@82o0nvCXpwLMyzXFf>PM1Cv zb1`7e)*PfsamSvF0w`D;IRBa5^AWYyByLZv_qp!ZJ)Z69uI6a;8@gw2dj9ZObKH$@ z<>dYS!=bken@7>jW~2F+ zc_%hUN#|^dj*;v1O3rEa`jKc^_xWgIr5(O8Nk{6|h=Ltx>n;8uqiVG`;jKnluoY|{ zBq$;A7ylE4hv)hNfetEfszpA(1S?E|9wnv@9$EgLhVG8D>KZEl%`<>$E{;Q&D~gVK z=hl-`6YGj!)Ytmgc2Su=Cx;dJ*dLo|uWUO61ZZOeM~jOV$-24k2X)nIJC79Ox~N`c z{=Q_cbrHf!rmH4R2!M%p8V33$b~N=tNqYXHiDL;naJA_jm$Mlg=D#T-nmvAH21Zq^ zn!fFha+NZUowa-0t#(A->n{nIr8+E`(_yb$B-eG>v-NuhQqGF`dqXVmDEmq^TdnF?%bBJOH9zP-8|{rtR^y5IaMd%tGtb(|GKYB=VjIXoN37g^6&5>C*bp%ts08${ zoZ=Gc1hfk*B7oyLoId>@f$nI0xJ$}RO~U?XpLJ2*NjwF6iPJv(7EsU3zjyyKtJ^WU4U!2bd4fN;Lc%6`p+ zDKF#x#nV50av*@wut0=ItSbV(X0a(hJzIA%EBN{wA<3ft@3?WH4G<{6=)as`|7U~$ zBhxPZ_U(c>{?{;VV9&kS(D`vL<{a&T}^-2cdGjQ{VetZdF* z3U-${xk_`^*rb+=ajXCm*)~5LYkYJ8{Ir3bTCE9_HGYBFCi(1xEpd(e!#-5AM7V2d|i z9TKEcU4GtY@6;)l?a7tA>#b9NhQIh)pUJ=R_y0a@F~ULbdcOi*zN}0rWxJ3JCV|_{ z2P|>UAd%&gsk^7yAb>L5=ZixxK!?-0=Y3~cnx_IlzqRFBGKa+Dy;Il^R|D}Ktkh|P z$2$H@rus|uqee&IrSeKztV7@yN4J_{P>3kquDe90ESf^%cRsP+nLlqA4Gwb zHMa!_gh|$h9DbhENIHLta5ia_D5H5sN2hBfTgi;jG*yk;{;XWVyX4|?-Wowim#4kg z0thW%dsACvrEGxW$g;=&RIhntY?G8kaf5W-WqJ4mg6mWnxZ6L*OAvZv2{`^)_Zfa` zjF)}5VANEf6k3lUfhqUJb06U$Xga!$LJNX!eZWle1}{%LzMRXl6Di(T0uBUuD;aqj zLFdx-rhIYnr>k`94SxKmOYiyX06_z$0SKDJ4@dEv%kv;bNJ$$1}%+@A>7D+Ko2%JRd zQKYWOaQOYc2$qs-8ad-cD2CVG2fTJ1I0ky*T}qW^O$}!u5%h9`1nr(!vX{R%_(OgA zeb^UZzQGR4>W)TgaK1jOi&Wy90`j10%TrG$@6F9^K0q3EC}PV{)4)BQ`lD6kSPgps zsCpk)_`V*i?7MafQ z^D{m3cg>3kK!VZ*`J`5k`w(89iN0Vx(|+H?&Gy=FnR|w^m%(4S zfWQLS77|&|aw1}M!UZlIwORVTgvyx>`Jp|p0t`T{aJz;T#Vkwrz~k<^jmWEU?+yv3#9f>f-Fi3(Xo;DW&G zt@ngv>FAdr(53kO+o8r%l-El|$I07&{;bRDFUacuU_yue`84NEAbHMKY-+ml5x?0> z+g3Ki7wd0sPvOWEUld}`mkn{M{$tCDJK>#2Yug_~v+#{)Bds8@n?q(P)f9Aq9v5Ej zQ?`F{=UaEbRoGslUi_oEyVhI^UxaIt3Z=A+-e;v)j(5zsX6DaEc6l_Zh~(F1W5K6S zVV~vc-}5R$$1Lq^AY^B<$jFXpBjugW7f$mS zz!w~O_l6Dr?*qUS++=_IxM4s?av`*jA9=!wF5WSJ_AKF_M%mapErR~;cf?o=$o{R; zUiwcv&#Vk6uU`ZWY^6N^^Ja8lL^RQDpZ-2Mg#eTMb$f4r{^jQ8jAX#^BnZ^`NcQ4G zKa}W#7W@#T1WpA(poCXJ7a!j!|2`9B{hAq!Hmx6MZ~sOd9KL;6SIc$XZn(W&s@`W0 z(<4U?RE3ckk+%bPChHE1RNSGsUV(0%yj+DUj8=@6w9rj<=xOqrhctUY$BYCJl)4Z6(Dwd3_5=l&w_S|jEcZ|*;-K~3TOb8 zMZ^V#016}!WYty-BrMGmLfBhH6a|q*3~MWfkRXIbmavK_D2tIz2&=LO!kz>HA@78l z`KIU1d^7L8d4J@)x%a#0RMkn7PPeKmCIaC3$C6I5##UD`=C%FZOY+FWmXSP3(pZX>?_MD+E2ccH)5^EIX_j z4+!MjQo59#tUj{mh=vXk={1%TSVkl;?)2}GxKPSWJ-4SqqYI+QE2zkQZDCR6HMk~7(-r5P+RhltAkRsmG?TT zOuvT`v%!9(comwp>A?MG`Z(01cS8;Zqx}ORy`g4NnZR#l<*w?dEL2~Xj7Xx-_NZ&( zaazaKF7mV-Vj}K9%k*=RNmxCMNv^eh(!m_zQ1D z$e0tV=7%#VvM85Ma9|TIOz;M#sf8}mjPC3*y^ zOb}4vzc(koFxHN=sD>LA7`zm0Sw`6nW24Mq>OG$et&2eb?aqHSlu4g6HIe#OmlWrAEHJ_n{9~+ur2CF zDK6@pKeeYl4$kukywcknaW-cMxOrxgnl@(?2;LCVFTzDhNJ~>oOI>nuuEfP{R_32O z-#Ekm>e$%$v!S#Sc*R5tfM08Ct9Z2N?r(L$A)a|E7%w9})Uyne3l%gPB6Z89woy|> z?tg=+KdOSULWdgvDIDjjLe!KW>=GoQA8PGRiJC!Ew&8wH$bXnN%B2#xT%4q|bjt!C zZk&xrt!>Kb87X$IuUF008*R%7sRtGV`~M2%H1D+h8P=Np>U^{pe_~sujtzGJ)f?Iv zf_~xIJx}DcUD;G{4K~uM=Rf_sd5p7HYnz&RKa8#rKpqqf4wul37Yz)Iq`+4*KTI!t z3Z8zDIS0As?X5{(HZYky$0asvqstQ!y_~7^q;>aqTYM8iQsnWh$#5luAA|GIEB9dD zCzaQ8&>KQWtaQlYFq8M+;3hY&=(*J8)+*O$phdZtd$#yyf??(EycTyh>eIW0PY%;J z-|~b@s;UwZBJ!%gMS5tY*h0$+BcrSAMwe#07WNWp=0+&~Y6XW$vG9-ZyOb?*PIvcE z<*wdXWsV5fb8KIVu4|WD^^N}7rP+(Csa||(OCUkTCr6!^$9dhzZeE!&@bYl`WX>B? z7iZ(A`;-tABz&cDYP`SV{Ov0qjIEri^l!!S)A_LAqWIf0R%R(UNlf?~9Y~iNroDpD~ToaepT@_Ka7AIgzx`&_F4@t~) zX@B+4lK*(|ZnA8Xk0-5}mdKV*W-y!+leQU!$a_WO{molZSUQO)On$OZ_`xIdlxnooUlMCW*f`88$!#D?!U$JxGdOa@ei<`)=x zdVIr`zRo_)ZL%U$_r_HTr)b|B2ESLUmGUtg5zad068~i~XMLvF>crbZ4>5P>u5>(Q8jrLULnljfS& zRNQ`)Y+)_Ut`zILRDS}L(P@SepF}#p&6rA_61a*%(;f>tTxCtW&BdqZE+)_X+N%?n zb)csqpOFkF1y1EX37^V!4W0K0pPX}AIT7QoSVKh*DC>l}IH7HU+$kXU-o4@sC`X7{ zB|3f*?wutp-=WQH#v4Iksf7DF0j!~%Gopn_`@CE1b{9_Gb4i|bN>@;_S#~MUV zVwJO?DPw&DXcL}qhOov7Qp|v>PJrmM7#&q)0+_zIZzTiT=2GOtYFphqFnF8}V<;nB@=8@_(`jt_{h(zpt=p7cyV zzmOT}ERcM*z7$~O_BW2-{}WsP7t;WQ8(liSt#OBw%(W;%;&^%CUnY=$&gbtJK*oFW zQ3~?%_Nu?y2;g7jPwfiwbTW$O%KRG%mj_SMkJYDm7S6Bh=hCTJS-vt-BL_b{6m<0u zd;>}y)ep}nCMtK{z2#aHT$~TCqf3>5-2WkE{sH(`LJxZ)_HJ$(`14{x%I^>58HTT2 zcC}dB6r4|edA=Q>a(Ve9#F~=ocX44|TI6Qh#k!eZ)~AFm|1H33?Uh(=Na%x+ZMy!L z`S0nvZOEmYCpoMSEPMT*3a71SA#tzW;H*oT#;nwpd<|1IQr2LPD?=uH?F&f_am*2b zXH7am5-^6av{`GC4P|T?lVT_#9}lyuh}I)i+oZg?9V-#jg00reY>No@cWf9-fwo3? z+l_g*@2hatk~OByEdL^Z`^jjmBhE4{=qvKeOQ5Aa3gr*YhFqJop=Bu7OL4X9cx%Z- z>(`c0QtG;Xp9Yz1C+4^?d%6fErT|o^rDb=SOS*cels<9W>&cqB%(H|uYHV5Z;e<|F zVm`myaX23AQmA49M>dtR!&$ai?w9##m0FW$_NGTE?PwDGee}4N4l;Zo`5JUw%ia$c zv){_R*gTUKnAc`Vd8TGnL3w3U*z_SI5ssNGcXE%%h)l6Je^~ll{0ExefESb&%b%1E zMvi&CWp0FIoJd;w5_3WS1d20}l**jK&%E+NZmoczf6+qV{ie*g{{DH*3J!Qi#63`1 zDAxQ10o>v*1|=j<&r%LeF~ z6s1GokEhNFNY7OAKD5clBuA%6jRHQR4CFk`2gkJGZVIhNWS-6GG=@Z6zsmgV9kL5~ zVW?M_fy>;%yB=dcECo)xUv8Cxymp%Wu(bFw_0}L$7}k|5p5s`NWOy<%AkRxDqQ=2J zE-89TWAUND=D(2icT#r}AGP5W6Q8jM$QkMjfTNE|yoH>2Z(Oq}ai?>|ARHDtImSt7 z`Z$G~59D1fzYc#ym0Rf+O5XzPb$4f||Ev(t{jKq8x*ESj=Vkj58WCy{&r`;q=C6z)LU5^A$6;Y zX0a!qc8GwRPLf4?kg@?-rzk~G_TP>X>tQkrJ35M*nj!}60M*6?MHGDgoRAqK*vwNE z1FUpn!ae#lMEskyi4_9A2aFewp2rXf%)hl4wmJQS?Lti`%KiU687bHD?Y6sR+znjQNExMD#&e*`-j+?IN!p zeLx%0s;GA9S_?HjVgg=>8U$6~&MVo@!@{Qa_Ag3Go~ESS+Q~ACxEW}9F}o?5k$No( zye32k{(g2{0oi-B>>lmD=hN1M_&5tKRHlOCZQXPinJ#Y})t7}x?JY=yrebECcE@{+ zo$BdvXGFag?HS@t2gUehi7R#YJZN{1E-;=lX>f-DE$=mbXLZANtPti&DE=XUQ!!^vmVH({rC@j;%0gJoEabEt1_n`k=+MDFo2uZfwf4$aDnTYX ze4xqZ7yEE4ExqkdV^l>SU-mgWGZ*7bthOZ#GzW!k*oFj|X-ew((=p>d7=&9jiYo_0 z!^OF>&-)zG(`Js&SM`v!+-hqeKh%O}z4MRk$hqZ(!E>4md}r!U|5FL`1Y^Z)<= diff --git a/docs/plugins/development.md b/docs/plugins/development.md deleted file mode 100644 index d488cad6b..000000000 --- a/docs/plugins/development.md +++ /dev/null @@ -1,433 +0,0 @@ -# Plugin Development - -!!! info "Help Improve the NetBox Plugins Framework!" - We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338). - -This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. - -Plugins can do a lot, including: - -* Create Django models to store data in the database -* Provide their own "pages" (views) in the web user interface -* Inject template content and navigation links -* Establish their own REST API endpoints -* Add custom request/response middleware - -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 - -Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: - -```no-highlight -project-name/ - - plugin_name/ - - templates/ - - plugin_name/ - - *.html - - __init__.py - - middleware.py - - navigation.py - - signals.py - - template_content.py - - urls.py - - views.py - - README - - setup.py -``` - -The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: - -* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. -* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. -* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens). - -The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. - -### Create setup.py - -`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: - -```python -from setuptools import find_packages, setup - -setup( - name='netbox-animal-sounds', - version='0.1', - description='An example NetBox plugin', - url='https://github.com/netbox-community/netbox-animal-sounds', - author='Jeremy Stretch', - license='Apache 2.0', - install_requires=[], - packages=find_packages(), - include_package_data=True, - zip_safe=False, -) -``` - -Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). - -!!! note - `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) - -### Define a PluginConfig - -The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: - -```python -from extras.plugins import PluginConfig - -class AnimalSoundsConfig(PluginConfig): - name = 'netbox_animal_sounds' - verbose_name = 'Animal Sounds' - description = 'An example plugin for development purposes' - version = '0.1' - author = 'Jeremy Stretch' - author_email = 'author@example.com' - base_url = 'animal-sounds' - required_settings = [] - default_settings = { - 'loud': False - } - -config = AnimalSoundsConfig -``` - -NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. - -#### PluginConfig Attributes - -| Name | Description | -| ---- |---------------------------------------------------------------------------------------------------------------| -| `name` | Raw plugin name; same as the plugin's source directory | -| `verbose_name` | Human-friendly name for the plugin | -| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | -| `description` | Brief description of the plugin's purpose | -| `author` | Name of plugin's author | -| `author_email` | Author's public email address | -| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | -| `required_settings` | A list of any configuration parameters that **must** be defined by the user | -| `default_settings` | A dictionary of configuration parameters and their default values | -| `min_version` | Minimum version of NetBox with which the plugin is compatible | -| `max_version` | Maximum version of NetBox with which the plugin is compatible | -| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | -| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | -| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | -| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | - -All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. - -### Create a Virtual Environment - -It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.) - -```shell -python3 -m venv /path/to/my/venv -``` - -You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) - -```shell -cd $VENV/lib/python3.8/site-packages/ -echo /opt/netbox/netbox > netbox.pth -``` - -### Install the Plugin for Development - -To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): - -```no-highlight -$ python setup.py develop -``` - -## Database Models - -If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. - -Below is an example `models.py` file containing a model with two character fields: - -```python -from django.db import models - -class Animal(models.Model): - name = models.CharField(max_length=50) - sound = models.CharField(max_length=50) - - def __str__(self): - return self.name -``` - -Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. - -!!! note - A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory. - -```no-highlight -$ ./manage.py makemigrations netbox_animal_sounds -Migrations for 'netbox_animal_sounds': - /home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py - - Create model Animal -``` - -Next, we can apply the migration to the database with the `migrate` command: - -```no-highlight -$ ./manage.py migrate netbox_animal_sounds -Operations to perform: - Apply all migrations: netbox_animal_sounds -Running migrations: - Applying netbox_animal_sounds.0001_initial... OK -``` - -For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). - -### Using the Django Admin Interface - -Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below: - -```python -from django.contrib import admin -from .models import Animal - -@admin.register(Animal) -class AnimalAdmin(admin.ModelAdmin): - list_display = ('name', 'sound') -``` - -This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view. - -![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png) - -## Views - -If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: - -```python -from django.shortcuts import render -from django.views.generic import View -from .models import Animal - -class RandomAnimalView(View): - """ - Display a randomly-selected animal. - """ - def get(self, request): - animal = Animal.objects.order_by('?').first() - return render(request, 'netbox_animal_sounds/animal.html', { - 'animal': animal, - }) -``` - -This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below. - -### Extending the Base Template - -NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks: - -* `title` - The page title -* `header` - The upper portion of the page -* `content` - The main page body -* `javascript` - A section at the end of the page for including Javascript code - -For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). - -```jinja2 -{% extends 'base/layout.html' %} - -{% block content %} - {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} -

    - {% if animal %} - The {{ animal.name|lower }} says - {% if config.loud %} - {{ animal.sound|upper }}! - {% else %} - {{ animal.sound }} - {% endif %} - {% else %} - No animals have been created yet! - {% endif %} -

    - {% endwith %} -{% endblock %} - -``` - -The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block. - -!!! note - Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of. - -Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths. - -```python -from django.urls import path -from . import views - -urlpatterns = [ - path('random/', views.RandomAnimalView.as_view(), name='random_animal'), -] -``` - -A URL pattern has three components: - -* `route` - The unique portion of the URL dedicated to this view -* `view` - The view itself -* `name` - A short name used to identify the URL path internally - -This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. - -## REST API Endpoints - -Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. - -First, we'll create a serializer for our `Animal` model, in `api/serializers.py`: - -```python -from rest_framework.serializers import ModelSerializer -from netbox_animal_sounds.models import Animal - -class AnimalSerializer(ModelSerializer): - - class Meta: - model = Animal - fields = ('id', 'name', 'sound') -``` - -Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`: - -```python -from rest_framework.viewsets import ModelViewSet -from netbox_animal_sounds.models import Animal -from .serializers import AnimalSerializer - -class AnimalViewSet(ModelViewSet): - queryset = Animal.objects.all() - serializer_class = AnimalSerializer -``` - -Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`. - -```python -from rest_framework import routers -from .views import AnimalViewSet - -router = routers.DefaultRouter() -router.register('animals', AnimalViewSet) -urlpatterns = router.urls -``` - -With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined. - -![NetBox REST API plugin endpoint](../media/plugins/plugin_rest_api_endpoint.png) - -!!! warning - This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have. - -## Navigation Menu Items - -To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. - -```python -from extras.plugins import PluginMenuButton, PluginMenuItem -from utilities.choices import ButtonColorChoices - -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) - ), -) -``` - -A `PluginMenuItem` has the following attributes: - -* `link` - The name of the URL path to which this menu item links -* `link_text` - The text presented to the user -* `permissions` - A list of permissions required to display this link (optional) -* `buttons` - An iterable of PluginMenuButton instances to display (optional) - -A `PluginMenuButton` has the following attributes: - -* `link` - The name of the URL path to which this button links -* `title` - The tooltip text (displayed when the mouse hovers over the button) -* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/)) -* `color` - One of the choices provided by `ButtonColorChoices` (optional) -* `permissions` - A list of permissions required to display this button (optional) - -!!! note - Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. - -## Extending Core Templates - -Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: - -* `left_page()` - Inject content on the left side of the page -* `right_page()` - Inject content on the right side of the page -* `full_width_page()` - Inject content across the entire bottom of the page -* `buttons()` - Add buttons to the top of the page - -Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. - -When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: - -* `object` - The object being viewed -* `request` - The current request -* `settings` - Global NetBox settings -* `config` - Plugin-specific configuration parameters - -For example, accessing `{{ request.user }}` within a template will return the current user. - -Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. - -```python -from extras.plugins import PluginTemplateExtension -from .models import Animal - -class SiteAnimalCount(PluginTemplateExtension): - model = 'dcim.site' - - def right_page(self): - return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ - 'animal_count': Animal.objects.count(), - }) - -template_extensions = [SiteAnimalCount] -``` - -## Background Tasks - -By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*. -These 3 core queues can be used out-of-the-box by plugins to define background tasks. - -Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration -is below: - -```python -class MyPluginConfig(PluginConfig): - name = 'myplugin' - ... - queues = [ - 'queue1', - 'queue2', - 'queue-whatever-the-name' - ] -``` - -The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*. -As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins. - -In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks. - -``` -python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name -``` diff --git a/docs/plugins/development/background-tasks.md b/docs/plugins/development/background-tasks.md new file mode 100644 index 000000000..7c7e2936b --- /dev/null +++ b/docs/plugins/development/background-tasks.md @@ -0,0 +1,27 @@ +# Background Tasks + +By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*. +These 3 core queues can be used out-of-the-box by plugins to define background tasks. + +Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration +is below: + +```python +class MyPluginConfig(PluginConfig): + name = 'myplugin' + ... + queues = [ + 'queue1', + 'queue2', + 'queue-whatever-the-name' + ] +``` + +The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*. +As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins. + +In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks. + +``` +python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name +``` diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md deleted file mode 100644 index 1a444ca2c..000000000 --- a/docs/plugins/development/generic-views.md +++ /dev/null @@ -1,96 +0,0 @@ -# Generic Views - -NetBox provides several generic view classes to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. - -| View Class | Description | -|------------|-------------| -| `ObjectView` | View a single object | -| `ObjectEditView` | Create or edit a single object | -| `ObjectDeleteView` | Delete a single object | -| `ObjectListView` | View a list of objects | -| `BulkImportView` | Import a set of new objects | -| `BulkEditView` | Edit multiple objects | -| `BulkDeleteView` | Delete multiple objects | - -!!! note - Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. - -### Example Usage - -```python -# views.py -from netbox.views.generic import ObjectEditView -from .models import Thing - -class ThingEditView(ObjectEditView): - queryset = Thing.objects.all() - template_name = 'myplugin/thing.html' - ... -``` - -## Object Views - -Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object. - -::: netbox.views.generic.base.BaseObjectView - rendering: - show_source: false - -::: netbox.views.generic.ObjectView - selection: - members: - - get_object - - get_template_name - rendering: - show_source: false - -::: netbox.views.generic.ObjectEditView - selection: - members: - - get_object - - alter_object - rendering: - show_source: false - -::: netbox.views.generic.ObjectDeleteView - selection: - members: - - get_object - rendering: - show_source: false - -## Multi-Object Views - -Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects. - -::: netbox.views.generic.base.BaseMultiObjectView - rendering: - show_source: false - -::: netbox.views.generic.ObjectListView - selection: - members: - - get_table - - export_table - - export_template - rendering: - show_source: false - -::: netbox.views.generic.BulkImportView - selection: - members: false - rendering: - show_source: false - -::: netbox.views.generic.BulkEditView - selection: - members: false - rendering: - show_source: false - -::: netbox.views.generic.BulkDeleteView - selection: - members: - - get_form - rendering: - show_source: false diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 31ce5fc2e..07a04f39f 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -1,3 +1,146 @@ # Plugins Development -TODO +!!! info "Help Improve the NetBox Plugins Framework!" + We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338). + +This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. + +Plugins can do a lot, including: + +* Create Django models to store data in the database +* Provide their own "pages" (views) in the web user interface +* Inject template content and navigation links +* Establish their own REST API endpoints +* Add custom request/response middleware + +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 + +Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: + +```no-highlight +project-name/ + - plugin_name/ + - templates/ + - plugin_name/ + - *.html + - __init__.py + - middleware.py + - navigation.py + - signals.py + - template_content.py + - urls.py + - views.py + - README + - setup.py +``` + +The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: + +* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. +* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. +* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens). + +The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. + +### Create setup.py + +`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: + +```python +from setuptools import find_packages, setup + +setup( + name='netbox-animal-sounds', + version='0.1', + description='An example NetBox plugin', + url='https://github.com/netbox-community/netbox-animal-sounds', + author='Jeremy Stretch', + license='Apache 2.0', + install_requires=[], + packages=find_packages(), + include_package_data=True, + zip_safe=False, +) +``` + +Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). + +!!! note + `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) + +### Define a PluginConfig + +The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: + +```python +from extras.plugins import PluginConfig + +class AnimalSoundsConfig(PluginConfig): + name = 'netbox_animal_sounds' + verbose_name = 'Animal Sounds' + description = 'An example plugin for development purposes' + version = '0.1' + author = 'Jeremy Stretch' + author_email = 'author@example.com' + base_url = 'animal-sounds' + required_settings = [] + default_settings = { + 'loud': False + } + +config = AnimalSoundsConfig +``` + +NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. + +#### PluginConfig Attributes + +| Name | Description | +| ---- |---------------------------------------------------------------------------------------------------------------| +| `name` | Raw plugin name; same as the plugin's source directory | +| `verbose_name` | Human-friendly name for the plugin | +| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `description` | Brief description of the plugin's purpose | +| `author` | Name of plugin's author | +| `author_email` | Author's public email address | +| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | +| `required_settings` | A list of any configuration parameters that **must** be defined by the user | +| `default_settings` | A dictionary of configuration parameters and their default values | +| `min_version` | Minimum version of NetBox with which the plugin is compatible | +| `max_version` | Maximum version of NetBox with which the plugin is compatible | +| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | +| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | +| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | + +All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. + +### Create a Virtual Environment + +It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.) + +```shell +python3 -m venv /path/to/my/venv +``` + +You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) + +```shell +cd $VENV/lib/python3.8/site-packages/ +echo /opt/netbox/netbox > netbox.pth +``` + +### Install the Plugin for Development + +To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): + +```no-highlight +$ python setup.py develop +``` diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md deleted file mode 100644 index 35eb9389f..000000000 --- a/docs/plugins/development/model-features.md +++ /dev/null @@ -1,64 +0,0 @@ -# Model Features - -## Enabling NetBox Features - -Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: - -* Custom fields -* Custom links -* Custom validation -* Export templates -* Journaling -* Tags -* Webhooks - -This class performs two crucial functions: - -1. Apply any fields, methods, or attributes necessary to the operation of these features -2. Register the model with NetBox as utilizing these feature - -Simply subclass BaseModel when defining a model in your plugin: - -```python -# models.py -from netbox.models import BaseModel - -class MyModel(BaseModel): - foo = models.CharField() - ... -``` - -## Enabling Features Individually - -If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.) - -```python -# models.py -from django.db.models import models -from netbox.models.features import ExportTemplatesMixin, TagsMixin - -class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): - foo = models.CharField() - ... -``` - -The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.) - -## Feature Mixins Reference - -!!! note - Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. - -::: netbox.models.features.CustomLinksMixin - -::: netbox.models.features.CustomFieldsMixin - -::: netbox.models.features.CustomValidationMixin - -::: netbox.models.features.ExportTemplatesMixin - -::: netbox.models.features.JournalingMixin - -::: netbox.models.features.TagsMixin - -::: netbox.models.features.WebhooksMixin diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md new file mode 100644 index 000000000..bf06faf08 --- /dev/null +++ b/docs/plugins/development/models.md @@ -0,0 +1,107 @@ +# Database Models + +## Creating Models + +If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. + +Below is an example `models.py` file containing a model with two character fields: + +```python +from django.db import models + +class Animal(models.Model): + name = models.CharField(max_length=50) + sound = models.CharField(max_length=50) + + def __str__(self): + return self.name +``` + +### Migrations + +Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. + +!!! note + A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory. + +```no-highlight +$ ./manage.py makemigrations netbox_animal_sounds +Migrations for 'netbox_animal_sounds': + /home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py + - Create model Animal +``` + +Next, we can apply the migration to the database with the `migrate` command: + +```no-highlight +$ ./manage.py migrate netbox_animal_sounds +Operations to perform: + Apply all migrations: netbox_animal_sounds +Running migrations: + Applying netbox_animal_sounds.0001_initial... OK +``` + +For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). + +## Enabling NetBox Features + +Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: + +* Custom fields +* Custom links +* Custom validation +* Export templates +* Journaling +* Tags +* Webhooks + +This class performs two crucial functions: + +1. Apply any fields, methods, or attributes necessary to the operation of these features +2. Register the model with NetBox as utilizing these feature + +Simply subclass BaseModel when defining a model in your plugin: + +```python +# models.py +from netbox.models import BaseModel + +class MyModel(BaseModel): + foo = models.CharField() + ... +``` + +### Enabling Features Individually + +If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.) + +```python +# models.py +from django.db.models import models +from netbox.models.features import ExportTemplatesMixin, TagsMixin + +class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.) + +## Feature Mixins Reference + +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. + +::: netbox.models.features.CustomLinksMixin + +::: netbox.models.features.CustomFieldsMixin + +::: netbox.models.features.CustomValidationMixin + +::: netbox.models.features.ExportTemplatesMixin + +::: netbox.models.features.JournalingMixin + +::: netbox.models.features.TagsMixin + +::: netbox.models.features.WebhooksMixin diff --git a/docs/plugins/development/rest-api.md b/docs/plugins/development/rest-api.md new file mode 100644 index 000000000..efe7b1127 --- /dev/null +++ b/docs/plugins/development/rest-api.md @@ -0,0 +1,46 @@ +# REST API + +Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. + +First, we'll create a serializer for our `Animal` model, in `api/serializers.py`: + +```python +from rest_framework.serializers import ModelSerializer +from netbox_animal_sounds.models import Animal + +class AnimalSerializer(ModelSerializer): + + class Meta: + model = Animal + fields = ('id', 'name', 'sound') +``` + +Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`: + +```python +from rest_framework.viewsets import ModelViewSet +from netbox_animal_sounds.models import Animal +from .serializers import AnimalSerializer + +class AnimalViewSet(ModelViewSet): + queryset = Animal.objects.all() + serializer_class = AnimalSerializer +``` + +Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`. + +```python +from rest_framework import routers +from .views import AnimalViewSet + +router = routers.DefaultRouter() +router.register('animals', AnimalViewSet) +urlpatterns = router.urls +``` + +With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined. + +![NetBox REST API plugin endpoint](../../media/plugins/plugin_rest_api_endpoint.png) + +!!! warning + This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have. diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md new file mode 100644 index 000000000..9c44e18ed --- /dev/null +++ b/docs/plugins/development/views.md @@ -0,0 +1,254 @@ +# Views + +If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: + +```python +from django.shortcuts import render +from django.views.generic import View +from .models import Animal + +class RandomAnimalView(View): + """ + Display a randomly-selected animal. + """ + def get(self, request): + animal = Animal.objects.order_by('?').first() + return render(request, 'netbox_animal_sounds/animal.html', { + 'animal': animal, + }) +``` + +This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below. + +## View Classes + +NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. + +| View Class | Description | +|------------|-------------| +| `ObjectView` | View a single object | +| `ObjectEditView` | Create or edit a single object | +| `ObjectDeleteView` | Delete a single object | +| `ObjectListView` | View a list of objects | +| `BulkImportView` | Import a set of new objects | +| `BulkEditView` | Edit multiple objects | +| `BulkDeleteView` | Delete multiple objects | + +!!! warning + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. + +### Example Usage + +```python +# views.py +from netbox.views.generic import ObjectEditView +from .models import Thing + +class ThingEditView(ObjectEditView): + queryset = Thing.objects.all() + template_name = 'myplugin/thing.html' + ... +``` + +## URL Registration + +To make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths. + +```python +from django.urls import path +from . import views + +urlpatterns = [ + path('random/', views.RandomAnimalView.as_view(), name='random_animal'), +] +``` + +A URL pattern has three components: + +* `route` - The unique portion of the URL dedicated to this view +* `view` - The view itself +* `name` - A short name used to identify the URL path internally + +This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. + +## Templates + +### Plugin Views + +NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks: + +* `title` - The page title +* `header` - The upper portion of the page +* `content` - The main page body +* `javascript` - A section at the end of the page for including Javascript code + +For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). + +```jinja2 +{% extends 'base/layout.html' %} + +{% block content %} + {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} +

    + {% if animal %} + The {{ animal.name|lower }} says + {% if config.loud %} + {{ animal.sound|upper }}! + {% else %} + {{ animal.sound }} + {% endif %} + {% else %} + No animals have been created yet! + {% endif %} +

    + {% endwith %} +{% endblock %} + +``` + +The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block. + +!!! note + Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of. + +### Extending Core Views + +Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: + +* `left_page()` - Inject content on the left side of the page +* `right_page()` - Inject content on the right side of the page +* `full_width_page()` - Inject content across the entire bottom of the page +* `buttons()` - Add buttons to the top of the page + +Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. + +When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: + +* `object` - The object being viewed +* `request` - The current request +* `settings` - Global NetBox settings +* `config` - Plugin-specific configuration parameters + +For example, accessing `{{ request.user }}` within a template will return the current user. + +Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. + +```python +from extras.plugins import PluginTemplateExtension +from .models import Animal + +class SiteAnimalCount(PluginTemplateExtension): + model = 'dcim.site' + + def right_page(self): + return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ + 'animal_count': Animal.objects.count(), + }) + +template_extensions = [SiteAnimalCount] +``` + +## Navigation Menu Items + +To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. + +```python +from extras.plugins import PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_animal_sounds:random_animal', + link_text='Random sound', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) + ), +) +``` + +A `PluginMenuItem` has the following attributes: + +* `link` - The name of the URL path to which this menu item links +* `link_text` - The text presented to the user +* `permissions` - A list of permissions required to display this link (optional) +* `buttons` - An iterable of PluginMenuButton instances to display (optional) + +A `PluginMenuButton` has the following attributes: + +* `link` - The name of the URL path to which this button links +* `title` - The tooltip text (displayed when the mouse hovers over the button) +* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/)) +* `color` - One of the choices provided by `ButtonColorChoices` (optional) +* `permissions` - A list of permissions required to display this button (optional) + +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. + +## Object Views Reference + +Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object. + +::: netbox.views.generic.base.BaseObjectView + rendering: + show_source: false + +::: netbox.views.generic.ObjectView + selection: + members: + - get_object + - get_template_name + rendering: + show_source: false + +::: netbox.views.generic.ObjectEditView + selection: + members: + - get_object + - alter_object + rendering: + show_source: false + +::: netbox.views.generic.ObjectDeleteView + selection: + members: + - get_object + rendering: + show_source: false + +## Multi-Object Views Reference + +Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects. + +::: netbox.views.generic.base.BaseMultiObjectView + rendering: + show_source: false + +::: netbox.views.generic.ObjectListView + selection: + members: + - get_table + - export_table + - export_template + rendering: + show_source: false + +::: netbox.views.generic.BulkImportView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkEditView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkDeleteView + selection: + members: + - get_form + rendering: + show_source: false diff --git a/mkdocs.yml b/mkdocs.yml index dbd31cb50..148e083d2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -101,10 +101,11 @@ nav: - Plugins: - Using Plugins: 'plugins/index.md' - Developing Plugins: - - Introduction: 'plugins/development/index.md' - - Model Features: 'plugins/development/model-features.md' - - Generic Views: 'plugins/development/generic-views.md' - - Developing Plugins (Old): 'plugins/development.md' + - Getting Started: 'plugins/development/index.md' + - Database Models: 'plugins/development/models.md' + - Views: 'plugins/development/views.md' + - REST API: 'plugins/development/rest-api.md' + - Background Tasks: 'plugins/development/background-tasks.md' - Administration: - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' From acc9ca7d7df98b147b34be8ccfc3fcac6f8fbb3c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 16:11:49 -0500 Subject: [PATCH 092/104] Move TagFilter to PrimaryFilterSet --- netbox/circuits/filtersets.py | 5 ----- netbox/dcim/filtersets.py | 22 ---------------------- netbox/ipam/filtersets.py | 14 -------------- netbox/netbox/filtersets.py | 5 +++++ netbox/tenancy/filtersets.py | 6 ------ netbox/virtualization/filtersets.py | 6 ------ netbox/wireless/filtersets.py | 4 ---- 7 files changed, 5 insertions(+), 57 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 0a90116bd..998a7bb6d 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,7 +3,6 @@ from django.db.models import Q from dcim.filtersets import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup -from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter @@ -61,7 +60,6 @@ class ProviderFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Provider @@ -94,7 +92,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Provider (slug)', ) - tag = TagFilter() class Meta: model = ProviderNetwork @@ -112,7 +109,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): class CircuitTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = CircuitType @@ -190,7 +186,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Circuit diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4dfb080bc..a7402fa5f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,7 +1,6 @@ import django_filters from django.contrib.auth.models import User -from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet from ipam.models import ASN, VRF from netbox.filtersets import ( @@ -79,7 +78,6 @@ class RegionFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) - tag = TagFilter() class Meta: model = Region @@ -97,7 +95,6 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) - tag = TagFilter() class Meta: model = SiteGroup @@ -148,7 +145,6 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): queryset=ASN.objects.all(), label='AS (ID)', ) - tag = TagFilter() class Meta: model = Site @@ -225,7 +221,6 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): to_field_name='slug', label='Location (slug)', ) - tag = TagFilter() class Meta: model = Location @@ -241,7 +236,6 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RackRole @@ -325,7 +319,6 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): serial = django_filters.CharFilter( lookup_expr='iexact' ) - tag = TagFilter() class Meta: model = Rack @@ -389,7 +382,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='username', label='User (name)', ) - tag = TagFilter() class Meta: model = RackReservation @@ -407,7 +399,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = Manufacturer @@ -461,7 +452,6 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = DeviceType @@ -546,7 +536,6 @@ class ModuleTypeFilterSet(PrimaryModelFilterSet): method='_pass_through_ports', label='Has pass-through ports', ) - tag = TagFilter() class Meta: model = ModuleType @@ -732,7 +721,6 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo class DeviceRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = DeviceRole @@ -751,7 +739,6 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) - tag = TagFilter() class Meta: model = Platform @@ -916,7 +903,6 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = Device @@ -990,7 +976,6 @@ class ModuleFilterSet(PrimaryModelFilterSet): queryset=Device.objects.all(), label='Device (ID)', ) - tag = TagFilter() class Meta: model = Module @@ -1080,7 +1065,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Virtual Chassis', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -1202,7 +1186,6 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() - tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1377,7 +1360,6 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = InventoryItemRole @@ -1447,7 +1429,6 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Tenant (slug)', ) - tag = TagFilter() class Meta: model = VirtualChassis @@ -1505,7 +1486,6 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): method='filter_device', field_name='device__site__slug' ) - tag = TagFilter() class Meta: model = Cable @@ -1571,7 +1551,6 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): lookup_expr='in', label='Location (ID)', ) - tag = TagFilter() class Meta: model = PowerPanel @@ -1641,7 +1620,6 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE choices=PowerFeedStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = PowerFeed diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 52e4499c7..aaba09bc6 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -6,7 +6,6 @@ from django.db.models import Q from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup -from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( @@ -63,7 +62,6 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='name', label='Export target (name)', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -106,7 +104,6 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='rd', label='Export VRF (RD)', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -122,7 +119,6 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RIRFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RIR @@ -152,7 +148,6 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='RIR (slug)', ) - tag = TagFilter() class Meta: model = Aggregate @@ -218,7 +213,6 @@ class RoleFilterSet(OrganizationalModelFilterSet): method='search', label='Search', ) - tag = TagFilter() class Meta: model = Role @@ -347,7 +341,6 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): choices=PrefixStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = Prefix @@ -453,7 +446,6 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): choices=IPRangeStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = IPRange @@ -578,7 +570,6 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): role = django_filters.MultipleChoiceFilter( choices=IPAddressRoleChoices ) - tag = TagFilter() class Meta: model = IPAddress @@ -664,7 +655,6 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): queryset=IPAddress.objects.all(), method='filter_related_ip' ) - tag = TagFilter() class Meta: model = FHRPGroup @@ -737,7 +727,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): cluster = django_filters.NumberFilter( method='filter_scope' ) - tag = TagFilter() class Meta: model = VLANGroup @@ -832,7 +821,6 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): queryset=VirtualMachine.objects.all(), method='get_for_virtualmachine' ) - tag = TagFilter() class Meta: model = VLAN @@ -864,7 +852,6 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet): field_name='ports', lookup_expr='contains' ) - tag = TagFilter() class Meta: model = ServiceTemplate @@ -906,7 +893,6 @@ class ServiceFilterSet(PrimaryModelFilterSet): field_name='ports', lookup_expr='contains' ) - tag = TagFilter() class Meta: model = Service diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index f42ab064b..3ddf252c7 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -120,6 +120,10 @@ class BaseFilterSet(django_filters.FilterSet): def get_additional_lookups(cls, existing_filter_name, existing_filter): new_filters = {} + # Skip on abstract models + if not cls._meta.model: + return {} + # Skip nonstandard lookup expressions if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: return {} @@ -214,6 +218,7 @@ class ChangeLoggedModelFilterSet(BaseFilterSet): class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): + tag = TagFilter() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index c8af89143..36f625507 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -1,7 +1,6 @@ import django_filters from django.db.models import Q -from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -33,7 +32,6 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) - tag = TagFilter() class Meta: model = TenantGroup @@ -58,7 +56,6 @@ class TenantFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) - tag = TagFilter() class Meta: model = Tenant @@ -119,7 +116,6 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) - tag = TagFilter() class Meta: model = ContactGroup @@ -127,7 +123,6 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): class ContactRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ContactRole @@ -152,7 +147,6 @@ class ContactFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) - tag = TagFilter() class Meta: model = Contact diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ed2775de2..0fe433bbc 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,7 +2,6 @@ import django_filters from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet @@ -20,7 +19,6 @@ __all__ = ( class ClusterTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ClusterType @@ -28,7 +26,6 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): class ClusterGroupFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ClusterGroup @@ -96,7 +93,6 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Cluster type (slug)', ) - tag = TagFilter() class Meta: model = Cluster @@ -217,7 +213,6 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf method='_has_primary_ip', label='Has a primary IP', ) - tag = TagFilter() class Meta: model = VirtualMachine @@ -278,7 +273,6 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet): mac_address = MultiValueMACAddressFilter( label='MAC address', ) - tag = TagFilter() class Meta: model = VMInterface diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 3fb173b1b..b95c18c9d 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,7 +2,6 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices -from extras.filters import TagFilter from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter @@ -25,7 +24,6 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): queryset=WirelessLANGroup.objects.all(), to_field_name='slug' ) - tag = TagFilter() class Meta: model = WirelessLANGroup @@ -57,7 +55,6 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): auth_cipher = django_filters.MultipleChoiceFilter( choices=WirelessAuthCipherChoices ) - tag = TagFilter() class Meta: model = WirelessLAN @@ -89,7 +86,6 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): auth_cipher = django_filters.MultipleChoiceFilter( choices=WirelessAuthCipherChoices ) - tag = TagFilter() class Meta: model = WirelessLink From 28de9b89132e063597e59e6c518adc260ab5c306 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 16:18:07 -0500 Subject: [PATCH 093/104] Refactor ChangeLoggedModelFilterSet --- netbox/netbox/filtersets.py | 48 +++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 3ddf252c7..a109b2c70 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -23,6 +23,31 @@ __all__ = ( ) +# +# Mixins +# + +class ChangeLoggedModelMixin: + created = django_filters.DateFilter() + created__gte = django_filters.DateFilter( + field_name='created', + lookup_expr='gte' + ) + created__lte = django_filters.DateFilter( + field_name='created', + lookup_expr='lte' + ) + last_updated = django_filters.DateTimeFilter() + last_updated__gte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='gte' + ) + last_updated__lte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='lte' + ) + + # # FilterSets # @@ -196,28 +221,11 @@ class BaseFilterSet(django_filters.FilterSet): return filters -class ChangeLoggedModelFilterSet(BaseFilterSet): - created = django_filters.DateFilter() - created__gte = django_filters.DateFilter( - field_name='created', - lookup_expr='gte' - ) - created__lte = django_filters.DateFilter( - field_name='created', - lookup_expr='lte' - ) - last_updated = django_filters.DateTimeFilter() - last_updated__gte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='gte' - ) - last_updated__lte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='lte' - ) +class ChangeLoggedModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): + pass -class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): +class PrimaryModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): tag = TagFilter() def __init__(self, *args, **kwargs): From e4abbfb2c6064d03091f3890a30343cfb788d8ea Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 17:37:06 -0500 Subject: [PATCH 094/104] Closes #8454: Set DEFAULT_AUTO_FIELD to BigAutoField --- docs/release-notes/version-3.2.md | 1 + .../migrations/0033_gfk_bigidfield.py | 18 -- .../migrations/0033_standardize_id_fields.py | 44 +++ netbox/dcim/migrations/0151_gfk_bigidfield.py | 73 ----- .../migrations/0151_standardize_id_fields.py | 274 ++++++++++++++++++ netbox/dcim/models/cables.py | 4 +- .../extras/migrations/0071_gfk_bigidfield.py | 33 --- .../migrations/0071_standardize_id_fields.py | 94 ++++++ netbox/extras/models/change_logging.py | 3 +- netbox/extras/models/models.py | 4 +- netbox/extras/models/tags.py | 4 +- netbox/ipam/migrations/0056_gfk_bigidfield.py | 23 -- .../migrations/0056_standardize_id_fields.py | 99 +++++++ netbox/netbox/models/__init__.py | 11 +- netbox/netbox/settings.py | 2 +- .../tenancy/migrations/0005_gfk_bigidfield.py | 18 -- .../migrations/0005_standardize_id_fields.py | 49 ++++ .../migrations/0002_standardize_id_fields.py | 26 ++ netbox/users/models.py | 5 +- .../migrations/0027_standardize_id_fields.py | 36 +++ .../migrations/0002_standardize_id_fields.py | 26 ++ netbox/wireless/models.py | 2 +- 22 files changed, 665 insertions(+), 184 deletions(-) delete mode 100644 netbox/circuits/migrations/0033_gfk_bigidfield.py create mode 100644 netbox/circuits/migrations/0033_standardize_id_fields.py delete mode 100644 netbox/dcim/migrations/0151_gfk_bigidfield.py create mode 100644 netbox/dcim/migrations/0151_standardize_id_fields.py delete mode 100644 netbox/extras/migrations/0071_gfk_bigidfield.py create mode 100644 netbox/extras/migrations/0071_standardize_id_fields.py delete mode 100644 netbox/ipam/migrations/0056_gfk_bigidfield.py create mode 100644 netbox/ipam/migrations/0056_standardize_id_fields.py delete mode 100644 netbox/tenancy/migrations/0005_gfk_bigidfield.py create mode 100644 netbox/tenancy/migrations/0005_standardize_id_fields.py create mode 100644 netbox/users/migrations/0002_standardize_id_fields.py create mode 100644 netbox/virtualization/migrations/0027_standardize_id_fields.py create mode 100644 netbox/wireless/migrations/0002_standardize_id_fields.py diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index c35806c04..789003cca 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -81,6 +81,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7743](https://github.com/netbox-community/netbox/issues/7743) - Remove legacy ASN field from site model * [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model * [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs +* [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys ### REST API Changes diff --git a/netbox/circuits/migrations/0033_gfk_bigidfield.py b/netbox/circuits/migrations/0033_gfk_bigidfield.py deleted file mode 100644 index 970617a88..000000000 --- a/netbox/circuits/migrations/0033_gfk_bigidfield.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0032_provider_service_id'), - ] - - operations = [ - migrations.AlterField( - model_name='circuittermination', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/circuits/migrations/0033_standardize_id_fields.py b/netbox/circuits/migrations/0033_standardize_id_fields.py new file mode 100644 index 000000000..475fc2527 --- /dev/null +++ b/netbox/circuits/migrations/0033_standardize_id_fields.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0032_provider_service_id'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='providernetwork', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='circuittermination', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0151_gfk_bigidfield.py b/netbox/dcim/migrations/0151_gfk_bigidfield.py deleted file mode 100644 index 733e6ecd5..000000000 --- a/netbox/dcim/migrations/0151_gfk_bigidfield.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0150_interface_speed_duplex'), - ] - - operations = [ - migrations.AlterField( - model_name='cable', - name='termination_a_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='cable', - name='termination_b_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='cablepath', - name='destination_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='cablepath', - name='origin_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='consoleport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='consoleserverport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='frontport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='interface', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='powerfeed', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='poweroutlet', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='powerport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='rearport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0151_standardize_id_fields.py b/netbox/dcim/migrations/0151_standardize_id_fields.py new file mode 100644 index 000000000..76fea859b --- /dev/null +++ b/netbox/dcim/migrations/0151_standardize_id_fields.py @@ -0,0 +1,274 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0150_interface_speed_duplex'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemtemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='location', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='module', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='moduletype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sitegroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='cable', + name='termination_a_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cable', + name='termination_b_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cablepath', + name='destination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cablepath', + name='origin_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='consoleport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e3cc20177..f1d4d7043 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from netbox.models import BigIDModel, PrimaryModel +from netbox.models import PrimaryModel from utilities.fields import ColorField from utilities.utils import to_meters from .devices import Device @@ -298,7 +298,7 @@ class Cable(PrimaryModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(BigIDModel): +class CablePath(models.Model): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do diff --git a/netbox/extras/migrations/0071_gfk_bigidfield.py b/netbox/extras/migrations/0071_gfk_bigidfield.py deleted file mode 100644 index 64ce3c471..000000000 --- a/netbox/extras/migrations/0071_gfk_bigidfield.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0070_customlink_enabled'), - ] - - operations = [ - migrations.AlterField( - model_name='imageattachment', - name='object_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='journalentry', - name='assigned_object_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='objectchange', - name='changed_object_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='objectchange', - name='related_object_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/extras/migrations/0071_standardize_id_fields.py b/netbox/extras/migrations/0071_standardize_id_fields.py new file mode 100644 index 000000000..fa2b132bf --- /dev/null +++ b/netbox/extras/migrations/0071_standardize_id_fields.py @@ -0,0 +1,94 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0070_customlink_enabled'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='configcontext', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='configrevision', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customfield', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customlink', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='exporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='imageattachment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='jobresult', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='journalentry', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='objectchange', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tag', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='taggeditem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='webhook', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='imageattachment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='journalentry', + name='assigned_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='changed_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='related_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 4e703833a..8444260c8 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -5,11 +5,10 @@ from django.db import models from django.urls import reverse from extras.choices import * -from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet -class ObjectChange(BigIDModel): +class ObjectChange(models.Model): """ Record a change to an object and the user account associated with that change. A change record may optionally indicate an object related to the one being changed. For example, a change to an interface may also indicate the diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 143bc7d9b..1ea4a01d4 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -18,7 +18,7 @@ from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet from extras.utils import FeatureQuery, image_upload -from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models import ChangeLoggedModel from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -467,7 +467,7 @@ class JournalEntry(WebhooksMixin, ChangeLoggedModel): return JournalEntryKindChoices.colors.get(self.kind) -class JobResult(BigIDModel): +class JobResult(models.Model): """ This model stores the results from running a user-defined report. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index df8446b9c..a4b3f080d 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -3,7 +3,7 @@ from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models import ChangeLoggedModel from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -36,7 +36,7 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): return slug -class TaggedItem(BigIDModel, GenericTaggedItemBase): +class TaggedItem(GenericTaggedItemBase): tag = models.ForeignKey( to=Tag, related_name="%(app_label)s_%(class)s_items", diff --git a/netbox/ipam/migrations/0056_gfk_bigidfield.py b/netbox/ipam/migrations/0056_gfk_bigidfield.py deleted file mode 100644 index f40f65271..000000000 --- a/netbox/ipam/migrations/0056_gfk_bigidfield.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0055_servicetemplate'), - ] - - operations = [ - migrations.AlterField( - model_name='fhrpgroupassignment', - name='interface_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='ipaddress', - name='assigned_object_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/ipam/migrations/0056_standardize_id_fields.py b/netbox/ipam/migrations/0056_standardize_id_fields.py new file mode 100644 index 000000000..cb7564450 --- /dev/null +++ b/netbox/ipam/migrations/0056_standardize_id_fields.py @@ -0,0 +1,99 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0055_servicetemplate'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='aggregate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='asn', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='fhrpgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='fhrpgroupassignment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='iprange', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='prefix', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rir', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='routetarget', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='service', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='servicetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlan', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlangroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vrf', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='fhrpgroupassignment', + name='interface_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 2db2e2602..3631cf7f4 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -7,7 +7,6 @@ from utilities.querysets import RestrictedQuerySet from netbox.models.features import * __all__ = ( - 'BigIDModel', 'ChangeLoggedModel', 'NestedGroupModel', 'OrganizationalModel', @@ -26,7 +25,7 @@ class BaseModel( ExportTemplatesMixin, JournalingMixin, TagsMixin, - WebhooksMixin, + WebhooksMixin ): class Meta: abstract = True @@ -44,7 +43,7 @@ class BigIDModel(models.Model): abstract = True -class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): """ Base model for all objects which support change logging. """ @@ -54,7 +53,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -64,7 +63,7 @@ class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel): abstract = True -class NestedGroupModel(BaseModel, ChangeLoggingMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -106,7 +105,7 @@ class NestedGroupModel(BaseModel, ChangeLoggingMixin, BigIDModel, MPTTModel): }) -class OrganizationalModel(BaseModel, ChangeLoggingMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, models.Model): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5808602a2..2c33ec862 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -406,7 +406,7 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. diff --git a/netbox/tenancy/migrations/0005_gfk_bigidfield.py b/netbox/tenancy/migrations/0005_gfk_bigidfield.py deleted file mode 100644 index 12bbde295..000000000 --- a/netbox/tenancy/migrations/0005_gfk_bigidfield.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tenancy', '0004_extend_tag_support'), - ] - - operations = [ - migrations.AlterField( - model_name='contactassignment', - name='object_id', - field=models.PositiveBigIntegerField(), - ), - ] diff --git a/netbox/tenancy/migrations/0005_standardize_id_fields.py b/netbox/tenancy/migrations/0005_standardize_id_fields.py new file mode 100644 index 000000000..514478f17 --- /dev/null +++ b/netbox/tenancy/migrations/0005_standardize_id_fields.py @@ -0,0 +1,49 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='contact', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactassignment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenant', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenantgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='contactassignment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + ] diff --git a/netbox/users/migrations/0002_standardize_id_fields.py b/netbox/users/migrations/0002_standardize_id_fields.py new file mode 100644 index 000000000..60191d916 --- /dev/null +++ b/netbox/users/migrations/0002_standardize_id_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_squashed_0011'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='token', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userconfig', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 0ce91363b..722ec5ba6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -11,7 +11,6 @@ from django.dispatch import receiver from django.utils import timezone from netbox.config import get_config -from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * @@ -187,7 +186,7 @@ def create_userconfig(instance, created, **kwargs): # REST API # -class Token(BigIDModel): +class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. @@ -246,7 +245,7 @@ class Token(BigIDModel): # Permissions # -class ObjectPermission(BigIDModel): +class ObjectPermission(models.Model): """ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects identified by ORM query parameters. diff --git a/netbox/virtualization/migrations/0027_standardize_id_fields.py b/netbox/virtualization/migrations/0027_standardize_id_fields.py new file mode 100644 index 000000000..01d7e8af1 --- /dev/null +++ b/netbox/virtualization/migrations/0027_standardize_id_fields.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0026_vminterface_bridge'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustergroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustertype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualmachine', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vminterface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/wireless/migrations/0002_standardize_id_fields.py b/netbox/wireless/migrations/0002_standardize_id_fields.py new file mode 100644 index 000000000..9e0b202c2 --- /dev/null +++ b/netbox/wireless/migrations/0002_standardize_id_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslan', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='wirelesslangroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='wirelesslink', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 843462ec6..621024d79 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,7 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, PrimaryModel from .choices import * from .constants import * From b797b08bcfe53fefc3add75f54cb54c2f4e760ea Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 09:02:04 -0500 Subject: [PATCH 095/104] Remove BigIDModel --- .../extras/migrations/0071_standardize_id_fields.py | 5 ----- netbox/extras/models/tags.py | 3 +++ netbox/netbox/models/__init__.py | 12 ------------ 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/netbox/extras/migrations/0071_standardize_id_fields.py b/netbox/extras/migrations/0071_standardize_id_fields.py index fa2b132bf..63e3051d8 100644 --- a/netbox/extras/migrations/0071_standardize_id_fields.py +++ b/netbox/extras/migrations/0071_standardize_id_fields.py @@ -54,11 +54,6 @@ class Migration(migrations.Migration): name='id', field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), ), - migrations.AlterField( - model_name='tag', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), migrations.AlterField( model_name='taggeditem', name='id', diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index a4b3f080d..a4e4049d7 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -14,6 +14,9 @@ from utilities.fields import ColorField # class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): + id = models.BigAutoField( + primary_key=True + ) color = ColorField( default=ColorChoices.COLOR_GREY ) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 3631cf7f4..638d27c1b 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -31,18 +31,6 @@ class BaseModel( abstract = True -class BigIDModel(models.Model): - """ - Abstract base model for all data objects. Ensures the use of a 64-bit PK. - """ - id = models.BigAutoField( - primary_key=True - ) - - class Meta: - abstract = True - - class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): """ Base model for all objects which support change logging. From eb00e202693dcccb836b3dbafa86ec254afc5d45 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 09:03:30 -0500 Subject: [PATCH 096/104] Revert "Refactor ChangeLoggedModelFilterSet" This reverts commit 28de9b89132e063597e59e6c518adc260ab5c306. --- netbox/netbox/filtersets.py | 48 ++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index a109b2c70..3ddf252c7 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -23,31 +23,6 @@ __all__ = ( ) -# -# Mixins -# - -class ChangeLoggedModelMixin: - created = django_filters.DateFilter() - created__gte = django_filters.DateFilter( - field_name='created', - lookup_expr='gte' - ) - created__lte = django_filters.DateFilter( - field_name='created', - lookup_expr='lte' - ) - last_updated = django_filters.DateTimeFilter() - last_updated__gte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='gte' - ) - last_updated__lte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='lte' - ) - - # # FilterSets # @@ -221,11 +196,28 @@ class BaseFilterSet(django_filters.FilterSet): return filters -class ChangeLoggedModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): - pass +class ChangeLoggedModelFilterSet(BaseFilterSet): + created = django_filters.DateFilter() + created__gte = django_filters.DateFilter( + field_name='created', + lookup_expr='gte' + ) + created__lte = django_filters.DateFilter( + field_name='created', + lookup_expr='lte' + ) + last_updated = django_filters.DateTimeFilter() + last_updated__gte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='gte' + ) + last_updated__lte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='lte' + ) -class PrimaryModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): +class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): tag = TagFilter() def __init__(self, *args, **kwargs): From b67859832afa52742defa0a5bd60f9be1ddbe8e4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 20:25:23 -0500 Subject: [PATCH 097/104] Refactor to_objectchange() --- netbox/circuits/models/circuits.py | 10 ++---- .../dcim/models/device_component_templates.py | 32 +++++++------------ netbox/dcim/models/device_components.py | 10 ++---- netbox/extras/models/models.py | 4 ++- netbox/ipam/models/ip.py | 5 +-- netbox/netbox/models/features.py | 3 +- netbox/virtualization/models.py | 5 +-- 7 files changed, 27 insertions(+), 42 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index e697caa0a..faea380f0 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -209,13 +209,9 @@ class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): - # Annotate the parent Circuit - try: - circuit = self.circuit - except Circuit.DoesNotExist: - # Parent circuit has been deleted - circuit = None - return super().to_objectchange(action, related_object=circuit) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.circuit + return objectchange @property def parent_object(self): diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 72ac9df40..0538704d2 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -70,14 +70,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): """ raise NotImplementedError() - def to_objectchange(self, action, related_object=None): - # Annotate the parent DeviceType - try: - device_type = self.device_type - except ObjectDoesNotExist: - # The parent DeviceType has already been deleted - device_type = None - return super().to_objectchange(action, related_object=device_type) + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device_type + return objectchange class ModularComponentTemplateModel(ComponentTemplateModel): @@ -102,19 +98,13 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True - def to_objectchange(self, action, related_object=None): - # Annotate the parent DeviceType or ModuleType - try: - if getattr(self, 'device_type'): - return super().to_objectchange(action, related_object=self.device_type) - except ObjectDoesNotExist: - pass - try: - if getattr(self, 'module_type'): - return super().to_objectchange(action, related_object=self.module_type) - except ObjectDoesNotExist: - pass - return super().to_objectchange(action) + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + if self.device_type is not None: + objectchange.related_object = self.device_type + elif self.module_type is not None: + objectchange.related_object = self.module_type + return objectchange def clean(self): super().clean() diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9071dfe46..de22708ea 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -75,13 +75,9 @@ class ComponentModel(PrimaryModel): return self.name def to_objectchange(self, action): - # Annotate the parent Device - try: - device = self.device - except ObjectDoesNotExist: - # The parent Device has already been deleted - device = None - return super().to_objectchange(action, related_object=device) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device + return super().to_objectchange(action) @property def parent_object(self): diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 1ea4a01d4..afcb6556c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -418,7 +418,9 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel): return None def to_objectchange(self, action): - return super().to_objectchange(action, related_object=self.parent) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.parent + return objectchange class JournalEntry(WebhooksMixin, ChangeLoggedModel): diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 632d71034..b13899f7c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -904,8 +904,9 @@ class IPAddress(PrimaryModel): super().save(*args, **kwargs) def to_objectchange(self, action): - # Annotate the assigned object, if any - return super().to_objectchange(action, related_object=self.assigned_object) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.assigned_object + return objectchange @property def family(self): diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ce3980459..19b804b01 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -57,7 +57,7 @@ class ChangeLoggingMixin(models.Model): logger.debug(f"Taking a snapshot of {self}") self._prechange_snapshot = serialize_object(self) - def to_objectchange(self, action, related_object=None): + def to_objectchange(self, action): """ Return a new ObjectChange representing a change made to this object. This will typically be called automatically by ChangeLoggingMiddleware. @@ -65,7 +65,6 @@ class ChangeLoggingMixin(models.Model): from extras.models import ObjectChange objectchange = ObjectChange( changed_object=self, - related_object=related_object, object_repr=str(self)[:200], action=action ) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index d2f513f0b..790cdcdbf 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -441,8 +441,9 @@ class VMInterface(PrimaryModel, BaseInterface): }) def to_objectchange(self, action): - # Annotate the parent VirtualMachine - return super().to_objectchange(action, related_object=self.virtual_machine) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.virtual_machine + return objectchange @property def parent_object(self): From a795b95f7edcdc84a753e05d9907c1592b858a87 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 20:41:41 -0500 Subject: [PATCH 098/104] Closes #8451: Include ChangeLoggingMixin in BaseModel --- docs/plugins/development/models.md | 3 +++ netbox/netbox/models/__init__.py | 7 ++++--- netbox/netbox/models/features.py | 4 ---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index bf06faf08..b8b1e3122 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -47,6 +47,7 @@ For more background on schema migrations, see the [Django documentation](https:/ Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: +* Change logging * Custom fields * Custom links * Custom validation @@ -92,6 +93,8 @@ The example above will enable export templates and tags, but no other NetBox fea !!! note Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. +::: netbox.models.features.ChangeLoggingMixin + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 638d27c1b..bf120c8ac 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -19,6 +19,7 @@ __all__ = ( # class BaseModel( + ChangeLoggingMixin, CustomFieldsMixin, CustomLinksMixin, CustomValidationMixin, @@ -41,7 +42,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class PrimaryModel(BaseModel, ChangeLoggingMixin, models.Model): +class PrimaryModel(BaseModel, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -51,7 +52,7 @@ class PrimaryModel(BaseModel, ChangeLoggingMixin, models.Model): abstract = True -class NestedGroupModel(BaseModel, ChangeLoggingMixin, MPTTModel): +class NestedGroupModel(BaseModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -93,7 +94,7 @@ class NestedGroupModel(BaseModel, ChangeLoggingMixin, MPTTModel): }) -class OrganizationalModel(BaseModel, ChangeLoggingMixin, models.Model): +class OrganizationalModel(BaseModel, models.Model): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 19b804b01..24b9a4bff 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,5 +1,3 @@ -import logging - from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -53,8 +51,6 @@ class ChangeLoggingMixin(models.Model): """ Save a snapshot of the object's current state in preparation for modification. """ - logger = logging.getLogger('netbox') - logger.debug(f"Taking a snapshot of {self}") self._prechange_snapshot = serialize_object(self) def to_objectchange(self, action): From c5650bb2787c2fe1f55234904c6e0570f2eab17d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 20:57:14 -0500 Subject: [PATCH 099/104] Rename PrimaryModel to NetBoxModel --- docs/development/adding-models.md | 2 +- docs/plugins/development/models.md | 7 ++++--- netbox/circuits/models/circuits.py | 4 ++-- netbox/circuits/models/providers.py | 6 +++--- netbox/dcim/models/cables.py | 4 ++-- netbox/dcim/models/device_components.py | 4 ++-- netbox/dcim/models/devices.py | 12 ++++++------ netbox/dcim/models/power.py | 6 +++--- netbox/dcim/models/racks.py | 6 +++--- netbox/dcim/models/sites.py | 4 ++-- netbox/ipam/models/fhrp.py | 4 ++-- netbox/ipam/models/ip.py | 12 ++++++------ netbox/ipam/models/services.py | 6 +++--- netbox/ipam/models/vlans.py | 4 ++-- netbox/ipam/models/vrfs.py | 6 +++--- netbox/netbox/models/__init__.py | 21 +++++++++++---------- netbox/tenancy/models/contacts.py | 4 ++-- netbox/tenancy/models/tenants.py | 4 ++-- netbox/virtualization/models.py | 8 ++++---- netbox/wireless/models.py | 6 +++--- 20 files changed, 66 insertions(+), 64 deletions(-) diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index d55afb2f2..f4d171f48 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -2,7 +2,7 @@ ## 1. Define the model class -Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. +Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be NetBoxModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. Each model should define, at a minimum: diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index b8b1e3122..225ac3d92 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -65,9 +65,10 @@ Simply subclass BaseModel when defining a model in your plugin: ```python # models.py -from netbox.models import BaseModel +from django.db import models +from netbox.models import NetBoxModel -class MyModel(BaseModel): +class MyModel(NetBoxModel): foo = models.CharField() ... ``` @@ -78,7 +79,7 @@ If you prefer instead to enable only a subset of these features for a plugin mod ```python # models.py -from django.db.models import models +from django.db import models from netbox.models.features import ExportTemplatesMixin, TagsMixin class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index faea380f0..0f3de91ed 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,7 +5,7 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel from netbox.models.features import WebhooksMixin __all__ = ( @@ -43,7 +43,7 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -class Circuit(PrimaryModel): +class Circuit(NetBoxModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 8fd52c587..9cf4bf5c1 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,7 +3,7 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel __all__ = ( 'ProviderNetwork', @@ -11,7 +11,7 @@ __all__ = ( ) -class Provider(PrimaryModel): +class Provider(NetBoxModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -70,7 +70,7 @@ class Provider(PrimaryModel): return reverse('circuits:provider', args=[self.pk]) -class ProviderNetwork(PrimaryModel): +class ProviderNetwork(NetBoxModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or unimportant to the user. diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index f1d4d7043..0d46d3c8f 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.fields import ColorField from utilities.utils import to_meters from .devices import Device @@ -28,7 +28,7 @@ __all__ = ( # Cables # -class Cable(PrimaryModel): +class Cable(NetBoxModel): """ A physical connection between two endpoints. """ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index de22708ea..a6887a768 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager @@ -39,7 +39,7 @@ __all__ = ( ) -class ComponentModel(PrimaryModel): +class ComponentModel(NetBoxModel): """ An abstract model inherited by any model which has a parent Device. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f94c9757d..37c900286 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -14,7 +14,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * @@ -68,7 +68,7 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -class DeviceType(PrimaryModel): +class DeviceType(NetBoxModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -350,7 +350,7 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(PrimaryModel): +class ModuleType(NetBoxModel): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a @@ -569,7 +569,7 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -class Device(PrimaryModel, ConfigContextModel): +class Device(NetBoxModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -1005,7 +1005,7 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') -class Module(PrimaryModel, ConfigContextModel): +class Module(NetBoxModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. @@ -1087,7 +1087,7 @@ class Module(PrimaryModel, ConfigContextModel): # Virtual chassis # -class VirtualChassis(PrimaryModel): +class VirtualChassis(NetBoxModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index fe7f69df9..bbbdda83c 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,7 +6,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -20,7 +20,7 @@ __all__ = ( # Power # -class PowerPanel(PrimaryModel): +class PowerPanel(NetBoxModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -66,7 +66,7 @@ class PowerPanel(PrimaryModel): ) -class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): +class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 1ebbbcba4..0fe84aa0c 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -14,7 +14,7 @@ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG from netbox.config import get_config -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string @@ -63,7 +63,7 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(PrimaryModel): +class Rack(NetBoxModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -435,7 +435,7 @@ class Rack(PrimaryModel): return int(allocated_draw_total / available_power_total * 100) -class RackReservation(PrimaryModel): +class RackReservation(NetBoxModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 3756933ac..625422d6b 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,7 +7,7 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel from utilities.fields import NaturalOrderingField __all__ = ( @@ -194,7 +194,7 @@ class SiteGroup(NestedGroupModel): # Sites # -class Site(PrimaryModel): +class Site(NetBoxModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index f0e3c2a23..2a8d1bdcd 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NetBoxModel from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,7 +15,7 @@ __all__ = ( ) -class FHRPGroup(PrimaryModel): +class FHRPGroup(NetBoxModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) """ diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b13899f7c..1354c6e64 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,7 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -88,7 +88,7 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -class ASN(PrimaryModel): +class ASN(NetBoxModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have one or more ASNs assigned to it. @@ -147,7 +147,7 @@ class ASN(PrimaryModel): return self.asn -class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): +class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): """ 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. @@ -280,7 +280,7 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -class Prefix(GetAvailablePrefixesMixin, PrimaryModel): +class Prefix(GetAvailablePrefixesMixin, NetBoxModel): """ 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 @@ -557,7 +557,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -class IPRange(PrimaryModel): +class IPRange(NetBoxModel): """ A range of IP addresses, defined by start and end addresses. """ @@ -752,7 +752,7 @@ class IPRange(PrimaryModel): return int(float(child_count) / self.size * 100) -class IPAddress(PrimaryModel): +class IPAddress(NetBoxModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index bd8030a0a..70ad38197 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -6,7 +6,7 @@ from django.urls import reverse from ipam.choices import * from ipam.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.utils import array_to_string @@ -46,7 +46,7 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -class ServiceTemplate(ServiceBase, PrimaryModel): +class ServiceTemplate(ServiceBase, NetBoxModel): """ A template for a Service to be applied to a device or virtual machine. """ @@ -62,7 +62,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel): return reverse('ipam:servicetemplate', args=[self.pk]) -class Service(ServiceBase, PrimaryModel): +class Service(ServiceBase, NetBoxModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index f73571ea9..7cd03ed55 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -9,7 +9,7 @@ from dcim.models import Interface from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from virtualization.models import VMInterface @@ -116,7 +116,7 @@ class VLANGroup(OrganizationalModel): return None -class VLAN(PrimaryModel): +class VLAN(NetBoxModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index f1b2d682f..fc34b5488 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -2,7 +2,7 @@ from django.db import models from django.urls import reverse from ipam.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel __all__ = ( @@ -11,7 +11,7 @@ __all__ = ( ) -class VRF(PrimaryModel): +class VRF(NetBoxModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF @@ -73,7 +73,7 @@ class VRF(PrimaryModel): return reverse('ipam:vrf', args=[self.pk]) -class RouteTarget(PrimaryModel): +class RouteTarget(NetBoxModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. """ diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index bf120c8ac..b3bfe06c0 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -10,15 +10,11 @@ __all__ = ( 'ChangeLoggedModel', 'NestedGroupModel', 'OrganizationalModel', - 'PrimaryModel', + 'NetBoxModel', ) -# -# Base model classes -# - -class BaseModel( +class NetBoxFeatureSet( ChangeLoggingMixin, CustomFieldsMixin, CustomLinksMixin, @@ -32,9 +28,14 @@ class BaseModel( abstract = True +# +# Base model classes +# + class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): """ - Base model for all objects which support change logging. + Base model for ancillary models; provides limited functionality for models which don't + support NetBox's full feature set. """ objects = RestrictedQuerySet.as_manager() @@ -42,7 +43,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class PrimaryModel(BaseModel, models.Model): +class NetBoxModel(NetBoxFeatureSet, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -52,7 +53,7 @@ class PrimaryModel(BaseModel, models.Model): abstract = True -class NestedGroupModel(BaseModel, MPTTModel): +class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -94,7 +95,7 @@ class NestedGroupModel(BaseModel, MPTTModel): }) -class OrganizationalModel(BaseModel, models.Model): +class OrganizationalModel(NetBoxFeatureSet, models.Model): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index cacd682cb..81dd99773 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, NetBoxModel from netbox.models.features import WebhooksMixin from tenancy.choices import * @@ -76,7 +76,7 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -class Contact(PrimaryModel): +class Contact(NetBoxModel): """ Contact information for a particular object(s) in NetBox. """ diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index 9952a700d..88d8d52f1 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -3,7 +3,7 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel __all__ = ( 'Tenant', @@ -43,7 +43,7 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -class Tenant(PrimaryModel): +class Tenant(NetBoxModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 790cdcdbf..dda1d0bee 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -8,7 +8,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -100,7 +100,7 @@ class ClusterGroup(OrganizationalModel): # Clusters # -class Cluster(PrimaryModel): +class Cluster(NetBoxModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -183,7 +183,7 @@ class Cluster(PrimaryModel): # Virtual machines # -class VirtualMachine(PrimaryModel, ConfigContextModel): +class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -345,7 +345,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # Interfaces # -class VMInterface(PrimaryModel, BaseInterface): +class VMInterface(NetBoxModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 621024d79..fc80e91df 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,7 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel from .choices import * from .constants import * @@ -79,7 +79,7 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): +class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -117,7 +117,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslan', args=[self.pk]) -class WirelessLink(WirelessAuthenticationBase, PrimaryModel): +class WirelessLink(WirelessAuthenticationBase, NetBoxModel): """ A point-to-point connection between two wireless Interfaces. """ From 083d1acb81a2028acbe5d20ef0993061c93fda1a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 09:24:20 -0500 Subject: [PATCH 100/104] Closes #8453: Rename PrimaryModelFilterSet to NetBoxModelFilterSet & expose for plugins --- docs/plugins/development/filtersets.md | 56 ++++++++++++++++++++++++++ docs/plugins/development/models.md | 2 +- mkdocs.yml | 1 + netbox/circuits/filtersets.py | 8 ++-- netbox/dcim/filtersets.py | 44 ++++++++++---------- netbox/ipam/filtersets.py | 22 +++++----- netbox/netbox/filtersets.py | 11 +++-- netbox/tenancy/filtersets.py | 6 +-- netbox/virtualization/filtersets.py | 8 ++-- netbox/wireless/filtersets.py | 6 +-- 10 files changed, 112 insertions(+), 52 deletions(-) create mode 100644 docs/plugins/development/filtersets.md diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md new file mode 100644 index 000000000..e2a98ed0b --- /dev/null +++ b/docs/plugins/development/filtersets.md @@ -0,0 +1,56 @@ +# Filter Sets + +Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. + +## FilterSet Classes + +To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below. + +```python +# filtersets.py +import django_filters +from netbox.filtersets import NetBoxModelFilterSet +from .models import MyModel + +class MyFilterSet(NetBoxModelFilterSet): + status = django_filters.MultipleChoiceFilter( + choices=( + ('foo', 'Foo'), + ('bar', 'Bar'), + ('baz', 'Baz'), + ), + null_value=None + ) + + class Meta: + model = MyModel + fields = ('some', 'other', 'fields') +``` + +## Declaring Filter Sets + +To utilize a filter set in the subclass of a generic view, such as `ObjectListView` or `BulkEditView`, set it as the `filterset` attribute on the view class: + +```python +# views.py +from netbox.views.generic import ObjectListView +from .filtersets import MyModelFitlerSet +from .models import MyModel + +class MyModelListView(ObjectListView): + queryset = MyModel.objects.all() + filterset = MyModelFitlerSet +``` + +To enable a filter on a REST API endpoint, set it as the `filterset_class` attribute on the API view: + +```python +# api/views.py +from myplugin import models, filtersets +from . import serializers + +class MyModelViewSet(...): + queryset = models.MyModel.objects.all() + serializer_class = serializers.MyModelSerializer + filterset_class = filtersets.MyModelFilterSet +``` diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 225ac3d92..521420b1b 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -45,7 +45,7 @@ For more background on schema migrations, see the [Django documentation](https:/ ## Enabling NetBox Features -Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: +Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable numerous feature, including: * Change logging * Custom fields diff --git a/mkdocs.yml b/mkdocs.yml index 148e083d2..1a77cb195 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,7 @@ nav: - Getting Started: 'plugins/development/index.md' - Database Models: 'plugins/development/models.md' - Views: 'plugins/development/views.md' + - Filtersets: 'plugins/development/filtersets.md' - REST API: 'plugins/development/rest-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Administration: diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 998a7bb6d..40ac61e77 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.filtersets import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -18,7 +18,7 @@ __all__ = ( ) -class ProviderFilterSet(PrimaryModelFilterSet): +class ProviderFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -77,7 +77,7 @@ class ProviderFilterSet(PrimaryModelFilterSet): ) -class ProviderNetworkFilterSet(PrimaryModelFilterSet): +class ProviderNetworkFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -115,7 +115,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug'] -class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a7402fa5f..dda6de5b1 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from extras.filtersets import LocalConfigContextFilterSet from ipam.models import ASN, VRF from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, + BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant @@ -101,7 +101,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -242,7 +242,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -339,7 +339,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -405,7 +405,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class DeviceTypeFilterSet(PrimaryModelFilterSet): +class DeviceTypeFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -497,7 +497,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): return queryset.exclude(devicebaytemplates__isnull=value) -class ModuleTypeFilterSet(PrimaryModelFilterSet): +class ModuleTypeFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -745,7 +745,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] -class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): +class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -956,7 +956,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex return queryset.exclude(devicebays__isnull=value) -class ModuleFilterSet(PrimaryModelFilterSet): +class ModuleFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1096,7 +1096,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) -class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsolePortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -1107,7 +1107,7 @@ class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'description'] -class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -1118,7 +1118,7 @@ class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet fields = ['id', 'name', 'label', 'description'] -class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -1129,7 +1129,7 @@ class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -1144,7 +1144,7 @@ class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'feed_leg', 'description'] -class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1271,7 +1271,7 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT }.get(value, queryset.none()) -class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1282,7 +1282,7 @@ class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'type', 'color', 'description'] -class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class RearPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1293,21 +1293,21 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] -class ModuleBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class Meta: model = ModuleBay fields = ['id', 'name', 'label', 'description'] -class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'label', 'description'] -class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1366,7 +1366,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'color'] -class VirtualChassisFilterSet(PrimaryModelFilterSet): +class VirtualChassisFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1445,7 +1445,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() -class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): +class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1504,7 +1504,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset -class PowerPanelFilterSet(PrimaryModelFilterSet): +class PowerPanelFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1565,7 +1565,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index aaba09bc6..207b5e2dc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -6,7 +6,7 @@ from django.db.models import Q from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -35,7 +35,7 @@ __all__ = ( ) -class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -77,7 +77,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): fields = ['id', 'name', 'rd', 'enforce_unique'] -class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -125,7 +125,7 @@ class RIRFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'is_private', 'description'] -class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -219,7 +219,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug'] -class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -409,7 +409,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): +class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -475,7 +475,7 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset.none() -class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -640,7 +640,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.exclude(assigned_object_id__isnull=value) -class FHRPGroupFilterSet(PrimaryModelFilterSet): +class FHRPGroupFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -748,7 +748,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): ) -class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -843,7 +843,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.get_for_virtualmachine(value) -class ServiceTemplateFilterSet(PrimaryModelFilterSet): +class ServiceTemplateFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -864,7 +864,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class ServiceFilterSet(PrimaryModelFilterSet): +class ServiceFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 3ddf252c7..e36b9dd1d 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -18,8 +18,8 @@ from utilities import filters __all__ = ( 'BaseFilterSet', 'ChangeLoggedModelFilterSet', + 'NetBoxModelFilterSet', 'OrganizationalModelFilterSet', - 'PrimaryModelFilterSet', ) @@ -29,7 +29,7 @@ __all__ = ( class BaseFilterSet(django_filters.FilterSet): """ - A base FilterSet which provides common functionality to all NetBox FilterSets + A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class. """ FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) FILTER_DEFAULTS.update({ @@ -217,7 +217,10 @@ class ChangeLoggedModelFilterSet(BaseFilterSet): ) -class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): +class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): + """ + Provides additional filtering functionality (e.g. tags, custom fields) for core NetBox models. + """ tag = TagFilter() def __init__(self, *args, **kwargs): @@ -244,7 +247,7 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): self.filters.update(custom_field_filters) -class OrganizationalModelFilterSet(PrimaryModelFilterSet): +class OrganizationalModelFilterSet(NetBoxModelFilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields """ diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 36f625507..28087fecf 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -38,7 +38,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class TenantFilterSet(PrimaryModelFilterSet): +class TenantFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -129,7 +129,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug'] -class ContactFilterSet(PrimaryModelFilterSet): +class ContactFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 0fe433bbc..28b23e8a8 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * @@ -32,7 +32,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -107,7 +107,7 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): +class VirtualMachineFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -233,7 +233,7 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf return queryset.exclude(params) -class VMInterfaceFilterSet(PrimaryModelFilterSet): +class VMInterfaceFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index b95c18c9d..a88179acf 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from ipam.models import VLAN -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -30,7 +30,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class WirelessLANFilterSet(PrimaryModelFilterSet): +class WirelessLANFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -70,7 +70,7 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class WirelessLinkFilterSet(PrimaryModelFilterSet): +class WirelessLinkFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', From 4a1b4e04853d73eb3d7829744dd689343875c369 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 15:00:10 -0500 Subject: [PATCH 101/104] Closes #8469: Move BaseTable, columns to netbox core app --- netbox/circuits/tables.py | 31 +++-- netbox/circuits/views.py | 3 +- netbox/dcim/tables/__init__.py | 8 +- netbox/dcim/tables/cables.py | 12 +- netbox/dcim/tables/devices.py | 115 +++++++++--------- netbox/dcim/tables/devicetypes.py | 46 ++++--- netbox/dcim/tables/modules.py | 16 +-- netbox/dcim/tables/power.py | 18 +-- netbox/dcim/tables/racks.py | 31 +++-- netbox/dcim/tables/sites.py | 42 +++---- netbox/dcim/views.py | 2 +- netbox/extras/tables.py | 63 +++++----- netbox/extras/views.py | 2 +- netbox/ipam/tables/fhrp.py | 12 +- netbox/ipam/tables/ip.py | 66 +++++----- netbox/ipam/tables/services.py | 10 +- netbox/ipam/tables/vlans.py | 35 +++--- netbox/ipam/tables/vrfs.py | 18 +-- netbox/ipam/views.py | 2 +- .../{utilities => netbox}/tables/__init__.py | 10 -- .../{utilities => netbox}/tables/columns.py | 0 netbox/{utilities => netbox}/tables/tables.py | 2 +- netbox/netbox/views/generic/bulk_views.py | 2 +- netbox/netbox/views/generic/object_views.py | 2 +- netbox/tenancy/tables.py | 42 +++---- netbox/tenancy/views.py | 2 +- netbox/users/tests/test_preferences.py | 2 +- netbox/utilities/tables.py | 7 ++ netbox/utilities/tests/test_tables.py | 4 +- netbox/virtualization/tables.py | 39 +++--- netbox/virtualization/views.py | 2 +- netbox/wireless/tables.py | 22 ++-- netbox/wireless/views.py | 2 +- 33 files changed, 321 insertions(+), 349 deletions(-) rename netbox/{utilities => netbox}/tables/__init__.py (91%) rename netbox/{utilities => netbox}/tables/columns.py (100%) rename netbox/{utilities => netbox}/tables/tables.py (99%) create mode 100644 netbox/utilities/tables.py diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index b5fdc5440..0ffb8f03b 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,11 +1,10 @@ import django_tables2 as tables from django_tables2.utils import Accessor +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from .models import * - __all__ = ( 'CircuitTable', 'CircuitTypeTable', @@ -22,11 +21,11 @@ CIRCUITTERMINATION_LINK = """ {% endif %} """ + # # Table columns # - class CommitRateColumn(tables.TemplateColumn): """ Humanize the commit rate in the column view @@ -43,13 +42,13 @@ class CommitRateColumn(tables.TemplateColumn): def value(self, value): return str(value) if value else None + # # Providers # - class ProviderTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -57,8 +56,8 @@ class ProviderTable(BaseTable): accessor=Accessor('count_circuits'), verbose_name='Circuits' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:provider_list' ) @@ -76,15 +75,15 @@ class ProviderTable(BaseTable): # class ProviderNetworkTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) provider = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:providernetwork_list' ) @@ -101,11 +100,11 @@ class ProviderNetworkTable(BaseTable): # class CircuitTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='circuits:circuittype_list' ) circuit_count = tables.Column( @@ -125,7 +124,7 @@ class CircuitTypeTable(BaseTable): # class CircuitTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() cid = tables.Column( linkify=True, verbose_name='Circuit ID' @@ -133,7 +132,7 @@ class CircuitTable(BaseTable): provider = tables.Column( linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() termination_a = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, @@ -144,8 +143,8 @@ class CircuitTable(BaseTable): verbose_name='Side Z' ) commit_rate = CommitRateColumn() - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:circuit_list' ) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 97e985dcd..3229977be 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,10 +5,9 @@ from django.shortcuts import get_object_or_404, redirect, render from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables -from .choices import CircuitTerminationSideChoices from .models import * diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 993ae0518..7567762fa 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,8 +1,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, BooleanColumn from dcim.models import ConsolePort, Interface, PowerPort +from netbox.tables import BaseTable, columns from .cables import * from .devices import * from .devicetypes import * @@ -36,7 +36,7 @@ class ConsoleConnectionTable(BaseTable): linkify=True, verbose_name='Console Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -67,7 +67,7 @@ class PowerConnectionTable(BaseTable): linkify=True, verbose_name='Power Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -101,7 +101,7 @@ class InterfaceConnectionTable(BaseTable): linkify=True, verbose_name='Interface B' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index bea2c0adf..addb67c33 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,8 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT __all__ = ( @@ -16,7 +16,7 @@ __all__ = ( # class CableTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() termination_a_parent = tables.TemplateColumn( template_code=CABLE_TERMINATION_PARENT, accessor=Accessor('termination_a'), @@ -41,14 +41,14 @@ class CableTable(BaseTable): linkify=True, verbose_name='Termination B' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() - length = TemplateColumn( + length = columns.TemplateColumn( template_code=CABLE_LENGTH, order_by='_abs_length' ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:cable_list' ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7b00a16e9..7dee2bcbe 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -5,11 +5,8 @@ from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, - MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, -) from .template_code import * __all__ = ( @@ -75,23 +72,23 @@ def get_interface_state_attribute(record): # class DeviceRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'role_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'role_id': 'pk'}, verbose_name='VMs' ) - color = ColorColumn() - vm_role = BooleanColumn() - tags = TagColumn( + color = columns.ColorColumn() + vm_role = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:devicerole_list' ) @@ -109,21 +106,21 @@ class DeviceRoleTable(BaseTable): # class PlatformTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'platform_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'platform_id': 'pk'}, verbose_name='VMs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:platform_list' ) @@ -143,12 +140,12 @@ class PlatformTable(BaseTable): # class DeviceTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -159,7 +156,7 @@ class DeviceTable(BaseTable): rack = tables.Column( linkify=True ) - device_role = ColoredLabelColumn( + device_role = columns.ColoredLabelColumn( verbose_name='Role' ) manufacturer = tables.Column( @@ -195,8 +192,8 @@ class DeviceTable(BaseTable): vc_priority = tables.Column( verbose_name='VC Priority' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:device_list' ) @@ -218,7 +215,7 @@ class DeviceImportTable(BaseTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -244,7 +241,7 @@ class DeviceImportTable(BaseTable): # class DeviceComponentTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() device = tables.Column( linkify=True ) @@ -274,22 +271,22 @@ class CableTerminationTable(BaseTable): cable = tables.Column( linkify=True ) - cable_color = ColorColumn( + cable_color = columns.ColorColumn( accessor='cable.color', orderable=False, verbose_name='Cable Color' ) - link_peer = TemplateColumn( + link_peer = columns.TemplateColumn( accessor='_link_peer', template_code=LINKTERMINATION, orderable=False, verbose_name='Link Peer' ) - mark_connected = BooleanColumn() + mark_connected = columns.BooleanColumn() class PathEndpointTable(CableTerminationTable): - connection = TemplateColumn( + connection = columns.TemplateColumn( accessor='_path.last_node', template_code=LINKTERMINATION, verbose_name='Connection', @@ -304,7 +301,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleport_list' ) @@ -323,7 +320,7 @@ class DeviceConsolePortTable(ConsolePortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=CONSOLEPORT_BUTTONS ) @@ -346,7 +343,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleserverport_list' ) @@ -366,7 +363,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=CONSOLESERVERPORT_BUTTONS ) @@ -389,7 +386,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerport_list' ) @@ -410,7 +407,7 @@ class DevicePowerPortTable(PowerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=POWERPORT_BUTTONS ) @@ -438,7 +435,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): power_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:poweroutlet_list' ) @@ -458,7 +455,7 @@ class DevicePowerOutletTable(PowerOutletTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=POWEROUTLET_BUTTONS ) @@ -477,7 +474,7 @@ class DevicePowerOutletTable(PowerOutletTable): class BaseInterfaceTable(BaseTable): - enabled = BooleanColumn() + enabled = columns.BooleanColumn() ip_addresses = tables.TemplateColumn( template_code=INTERFACE_IPADDRESSES, orderable=False, @@ -490,7 +487,7 @@ class BaseInterfaceTable(BaseTable): verbose_name='FHRP Groups' ) untagged_vlan = tables.Column(linkify=True) - tagged_vlans = TemplateColumn( + tagged_vlans = columns.TemplateColumn( template_code=INTERFACE_TAGGED_VLANS, orderable=False, verbose_name='Tagged VLANs' @@ -504,11 +501,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'args': [Accessor('device_id')], } ) - mgmt_only = BooleanColumn() + mgmt_only = columns.BooleanColumn() wireless_link = tables.Column( linkify=True ) - wireless_lans = TemplateColumn( + wireless_lans = columns.TemplateColumn( template_code=INTERFACE_WIRELESS_LANS, orderable=False, verbose_name='Wireless LANs' @@ -516,7 +513,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi vrf = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:interface_list' ) @@ -550,7 +547,7 @@ class DeviceInterfaceTable(InterfaceTable): linkify=True, verbose_name='LAG' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=INTERFACE_BUTTONS ) @@ -582,14 +579,14 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): 'args': [Accessor('device_id')], } ) - color = ColorColumn() + color = columns.ColorColumn() rear_port_position = tables.Column( verbose_name='Position' ) rear_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:frontport_list' ) @@ -612,7 +609,7 @@ class DeviceFrontPortTable(FrontPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=FRONTPORT_BUTTONS ) @@ -637,8 +634,8 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): 'args': [Accessor('device_id')], } ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rearport_list' ) @@ -658,7 +655,7 @@ class DeviceRearPortTable(RearPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=REARPORT_BUTTONS ) @@ -690,7 +687,7 @@ class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:devicebay_list' ) @@ -711,7 +708,7 @@ class DeviceDeviceBayTable(DeviceBayTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=DEVICEBAY_BUTTONS ) @@ -734,7 +731,7 @@ class ModuleBayTable(DeviceComponentTable): linkify=True, verbose_name='Installed module' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:modulebay_list' ) @@ -745,7 +742,7 @@ class ModuleBayTable(DeviceComponentTable): class DeviceModuleBayTable(ModuleBayTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=MODULEBAY_BUTTONS ) @@ -773,8 +770,8 @@ class InventoryItemTable(DeviceComponentTable): orderable=False, linkify=True ) - discovered = BooleanColumn() - tags = TagColumn( + discovered = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:inventoryitem_list' ) cable = None # Override DeviceComponentTable @@ -797,7 +794,7 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn() + actions = columns.ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItem @@ -811,17 +808,17 @@ class DeviceInventoryItemTable(InventoryItemTable): class InventoryItemRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - inventoryitem_count = LinkedCountColumn( + inventoryitem_count = columns.LinkedCountColumn( viewname='dcim:inventoryitem_list', url_params={'role_id': 'pk'}, verbose_name='Items' ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:inventoryitemrole_list' ) @@ -838,19 +835,19 @@ class InventoryItemRoleTable(BaseTable): # class VirtualChassisTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) master = tables.Column( linkify=True ) - member_count = LinkedCountColumn( + member_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:virtualchassis_list' ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 93832d706..0b4f04a2a 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -5,9 +5,7 @@ from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, -) +from netbox.tables import BaseTable, columns from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( @@ -31,7 +29,7 @@ __all__ = ( # class ManufacturerTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -45,7 +43,7 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:manufacturer_list' ) @@ -65,21 +63,21 @@ class ManufacturerTable(BaseTable): # class DeviceTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() model = tables.Column( linkify=True, verbose_name='Device Type' ) - is_full_depth = BooleanColumn( + is_full_depth = columns.BooleanColumn( verbose_name='Full Depth' ) - instance_count = LinkedCountColumn( + instance_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'device_type_id': 'pk'}, verbose_name='Instances' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:devicetype_list' ) @@ -99,7 +97,7 @@ class DeviceTypeTable(BaseTable): # class ComponentTemplateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() id = tables.Column( verbose_name='ID' ) @@ -112,7 +110,7 @@ class ComponentTemplateTable(BaseTable): class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -124,7 +122,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -136,7 +134,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -148,7 +146,7 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -160,10 +158,10 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class InterfaceTemplateTable(ComponentTemplateTable): - mgmt_only = BooleanColumn( + mgmt_only = columns.BooleanColumn( verbose_name='Management Only' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -178,8 +176,8 @@ class FrontPortTemplateTable(ComponentTemplateTable): rear_port_position = tables.Column( verbose_name='Position' ) - color = ColorColumn() - actions = ActionsColumn( + color = columns.ColorColumn() + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -191,8 +189,8 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): - color = ColorColumn() - actions = ActionsColumn( + color = columns.ColorColumn() + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -204,7 +202,7 @@ class RearPortTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) @@ -215,7 +213,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) @@ -226,7 +224,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class InventoryItemTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) role = tables.Column( diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 6d620433a..4a4c9d09a 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Module, ModuleType -from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn +from netbox.tables import BaseTable, columns __all__ = ( 'ModuleTable', @@ -10,18 +10,18 @@ __all__ = ( class ModuleTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() model = tables.Column( linkify=True, verbose_name='Module Type' ) - instance_count = LinkedCountColumn( + instance_count = columns.LinkedCountColumn( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, verbose_name='Instances' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:moduletype_list' ) @@ -36,7 +36,7 @@ class ModuleTypeTable(BaseTable): class ModuleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() device = tables.Column( linkify=True ) @@ -46,8 +46,8 @@ class ModuleTable(BaseTable): module_type = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:module_list' ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index c1ea8a34c..e1c0304a2 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import PowerFeed, PowerPanel -from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn +from netbox.tables import BaseTable, columns from .devices import CableTerminationTable __all__ = ( @@ -15,19 +15,19 @@ __all__ = ( # class PowerPanelTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) site = tables.Column( linkify=True ) - powerfeed_count = LinkedCountColumn( + powerfeed_count = columns.LinkedCountColumn( viewname='dcim:powerfeed_list', url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) @@ -44,7 +44,7 @@ class PowerPanelTable(BaseTable): # We're not using PathEndpointTable for PowerFeed because power connections # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -54,16 +54,16 @@ class PowerFeedTable(CableTerminationTable): rack = tables.Column( linkify=True ) - status = ChoiceFieldColumn() - type = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() + type = columns.ChoiceFieldColumn() max_utilization = tables.TemplateColumn( template_code="{{ value }}%" ) available_power = tables.Column( verbose_name='Available power (VA)' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:powerfeed_list' ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 55c6f9ba8..9e89d7b82 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -2,11 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, UtilizationColumn, -) __all__ = ( 'RackTable', @@ -20,11 +17,11 @@ __all__ = ( # class RackRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rackrole_list' ) @@ -42,7 +39,7 @@ class RackRoleTable(BaseTable): # class RackTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( order_by=('_name',), linkify=True @@ -54,27 +51,27 @@ class RackTable(BaseTable): linkify=True ) tenant = TenantColumn() - status = ChoiceFieldColumn() - role = ColoredLabelColumn() + status = columns.ChoiceFieldColumn() + role = columns.ColoredLabelColumn() u_height = tables.TemplateColumn( template_code="{{ record.u_height }}U", verbose_name='Height' ) - comments = MarkdownColumn() - device_count = LinkedCountColumn( + comments = columns.MarkdownColumn() + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'rack_id': 'pk'}, verbose_name='Devices' ) - get_utilization = UtilizationColumn( + get_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Space' ) - get_power_utilization = UtilizationColumn( + get_power_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Power' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rack_list' ) outer_width = tables.TemplateColumn( @@ -104,7 +101,7 @@ class RackTable(BaseTable): # class RackReservationTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() reservation = tables.Column( accessor='pk', linkify=True @@ -121,7 +118,7 @@ class RackReservationTable(BaseTable): orderable=False, verbose_name='Units' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 32bf000ef..7a4e2f34f 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,10 +1,8 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, -) from .template_code import LOCATION_BUTTONS __all__ = ( @@ -20,16 +18,16 @@ __all__ = ( # class RegionTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'region_id': 'pk'}, verbose_name='Sites' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:region_list' ) @@ -46,16 +44,16 @@ class RegionTable(BaseTable): # class SiteGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'group_id': 'pk'}, verbose_name='Sites' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) @@ -72,26 +70,26 @@ class SiteGroupTable(BaseTable): # class SiteTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() region = tables.Column( linkify=True ) group = tables.Column( linkify=True ) - asn_count = LinkedCountColumn( + asn_count = columns.LinkedCountColumn( accessor=tables.A('asns.count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name='ASNs' ) tenant = TenantColumn() - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:site_list' ) @@ -110,28 +108,28 @@ class SiteTable(BaseTable): # class LocationTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) site = tables.Column( linkify=True ) tenant = TenantColumn() - rack_count = LinkedCountColumn( + rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, verbose_name='Racks' ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'location_id': 'pk'}, verbose_name='Devices' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:location_list' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=LOCATION_BUTTONS ) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b3cff1a26..231a3ef09 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 7d60518b2..b235cd8e2 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,10 +1,7 @@ import django_tables2 as tables from django.conf import settings -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, - MarkdownColumn, ToggleColumn, -) +from netbox.tables import BaseTable, columns from .models import * __all__ = ( @@ -47,12 +44,12 @@ OBJECTCHANGE_REQUEST_ID = """ # class CustomFieldTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_types = ContentTypesColumn() - required = BooleanColumn() + content_types = columns.ContentTypesColumn() + required = columns.BooleanColumn() class Meta(BaseTable.Meta): model = CustomField @@ -68,13 +65,13 @@ class CustomFieldTable(BaseTable): # class CustomLinkTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_type = ContentTypeColumn() - enabled = BooleanColumn() - new_window = BooleanColumn() + content_type = columns.ContentTypeColumn() + enabled = columns.BooleanColumn() + new_window = columns.BooleanColumn() class Meta(BaseTable.Meta): model = CustomLink @@ -90,12 +87,12 @@ class CustomLinkTable(BaseTable): # class ExportTemplateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_type = ContentTypeColumn() - as_attachment = BooleanColumn() + content_type = columns.ContentTypeColumn() + as_attachment = columns.BooleanColumn() class Meta(BaseTable.Meta): model = ExportTemplate @@ -113,22 +110,22 @@ class ExportTemplateTable(BaseTable): # class WebhookTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_types = ContentTypesColumn() - enabled = BooleanColumn() - type_create = BooleanColumn( + content_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + type_create = columns.BooleanColumn( verbose_name='Create' ) - type_update = BooleanColumn( + type_update = columns.BooleanColumn( verbose_name='Update' ) - type_delete = BooleanColumn( + type_delete = columns.BooleanColumn( verbose_name='Delete' ) - ssl_validation = BooleanColumn( + ssl_validation = columns.BooleanColumn( verbose_name='SSL Validation' ) @@ -149,11 +146,11 @@ class WebhookTable(BaseTable): # class TagTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - color = ColorColumn() + color = columns.ColorColumn() class Meta(BaseTable.Meta): model = Tag @@ -167,7 +164,7 @@ class TaggedItemTable(BaseTable): linkify=lambda record: record.content_object.get_absolute_url(), accessor='content_object__id' ) - content_type = ContentTypeColumn( + content_type = columns.ContentTypeColumn( verbose_name='Type' ) content_object = tables.Column( @@ -182,11 +179,11 @@ class TaggedItemTable(BaseTable): class ConfigContextTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - is_active = BooleanColumn( + is_active = columns.BooleanColumn( verbose_name='Active' ) @@ -205,8 +202,8 @@ class ObjectChangeTable(BaseTable): linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - action = ChoiceFieldColumn() - changed_object_type = ContentTypeColumn( + action = columns.ChoiceFieldColumn() + changed_object_type = columns.ContentTypeColumn( verbose_name='Type' ) object_repr = tables.TemplateColumn( @@ -217,7 +214,7 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) - actions = ActionsColumn(sequence=()) + actions = columns.ActionsColumn(sequence=()) class Meta(BaseTable.Meta): model = ObjectChange @@ -232,7 +229,7 @@ class ObjectJournalTable(BaseTable): linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - kind = ChoiceFieldColumn() + kind = columns.ChoiceFieldColumn() comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) @@ -243,8 +240,8 @@ class ObjectJournalTable(BaseTable): class JournalEntryTable(ObjectJournalTable): - pk = ToggleColumn() - assigned_object_type = ContentTypeColumn( + pk = columns.ToggleColumn() + assigned_object_type = columns.ContentTypeColumn( verbose_name='Object type' ) assigned_object = tables.Column( @@ -252,7 +249,7 @@ class JournalEntryTable(ObjectJournalTable): orderable=False, verbose_name='Object' ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() class Meta(BaseTable.Meta): model = JournalEntry diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 59f922d82..0c59ae874 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index f9119126c..e200d6cac 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,7 +1,7 @@ import django_tables2 as tables -from utilities.tables import ActionsColumn, BaseTable, MarkdownColumn, TagColumn, ToggleColumn from ipam.models import * +from netbox.tables import BaseTable, columns __all__ = ( 'FHRPGroupTable', @@ -17,11 +17,11 @@ IPADDRESSES = """ class FHRPGroupTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() group_id = tables.Column( linkify=True ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() ip_addresses = tables.TemplateColumn( template_code=IPADDRESSES, orderable=False, @@ -30,7 +30,7 @@ class FHRPGroupTable(BaseTable): interface_count = tables.Column( verbose_name='Interfaces' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:fhrpgroup_list' ) @@ -44,7 +44,7 @@ class FHRPGroupTable(BaseTable): class FHRPGroupAssignmentTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() interface_parent = tables.Column( accessor=tables.A('interface.parent_object'), linkify=True, @@ -58,7 +58,7 @@ class FHRPGroupAssignmentTable(BaseTable): group = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index b2e4ef958..a69118da3 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -3,10 +3,8 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from ipam.models import * +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, UtilizationColumn, -) __all__ = ( 'AggregateTable', @@ -73,19 +71,19 @@ VRF_LINK = """ # class RIRTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - is_private = BooleanColumn( + is_private = columns.BooleanColumn( verbose_name='Private' ) - aggregate_count = LinkedCountColumn( + aggregate_count = columns.LinkedCountColumn( viewname='ipam:aggregate_list', url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:rir_list' ) @@ -103,13 +101,13 @@ class RIRTable(BaseTable): # class ASNTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() asn = tables.Column( accessor=tables.A('asn_asdot'), linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'asn_id': 'pk'}, verbose_name='Sites' @@ -126,7 +124,7 @@ class ASNTable(BaseTable): # class AggregateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() prefix = tables.Column( linkify=True, verbose_name='Aggregate' @@ -139,11 +137,11 @@ class AggregateTable(BaseTable): child_count = tables.Column( verbose_name='Prefixes' ) - utilization = UtilizationColumn( + utilization = columns.UtilizationColumn( accessor='get_utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:aggregate_list' ) @@ -161,21 +159,21 @@ class AggregateTable(BaseTable): # class RoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - prefix_count = LinkedCountColumn( + prefix_count = columns.LinkedCountColumn( viewname='ipam:prefix_list', url_params={'role_id': 'pk'}, verbose_name='Prefixes' ) - vlan_count = LinkedCountColumn( + vlan_count = columns.LinkedCountColumn( viewname='ipam:vlan_list', url_params={'role_id': 'pk'}, verbose_name='VLANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:role_list' ) @@ -192,7 +190,7 @@ class RoleTable(BaseTable): # Prefixes # -class PrefixUtilizationColumn(UtilizationColumn): +class PrefixUtilizationColumn(columns.UtilizationColumn): """ Extend UtilizationColumn to allow disabling the warning & danger thresholds for prefixes marked as fully utilized. @@ -208,7 +206,7 @@ class PrefixUtilizationColumn(UtilizationColumn): class PrefixTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() prefix = tables.TemplateColumn( template_code=PREFIX_LINK, attrs={'td': {'class': 'text-nowrap'}} @@ -222,7 +220,7 @@ class PrefixTable(BaseTable): accessor=Accessor('_depth'), verbose_name='Depth' ) - children = LinkedCountColumn( + children = columns.LinkedCountColumn( accessor=Accessor('_children'), viewname='ipam:prefix_list', url_params={ @@ -231,7 +229,7 @@ class PrefixTable(BaseTable): }, verbose_name='Children' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) vrf = tables.TemplateColumn( @@ -254,17 +252,17 @@ class PrefixTable(BaseTable): role = tables.Column( linkify=True ) - is_pool = BooleanColumn( + is_pool = columns.BooleanColumn( verbose_name='Pool' ) - mark_utilized = BooleanColumn( + mark_utilized = columns.BooleanColumn( verbose_name='Marked Utilized' ) utilization = PrefixUtilizationColumn( accessor='get_utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:prefix_list' ) @@ -286,7 +284,7 @@ class PrefixTable(BaseTable): # IP ranges # class IPRangeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() start_address = tables.Column( linkify=True ) @@ -294,18 +292,18 @@ class IPRangeTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) role = tables.Column( linkify=True ) tenant = TenantColumn() - utilization = UtilizationColumn( + utilization = columns.UtilizationColumn( accessor='utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:iprange_list' ) @@ -328,7 +326,7 @@ class IPRangeTable(BaseTable): # class IPAddressTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() address = tables.TemplateColumn( template_code=IPADDRESS_LINK, verbose_name='IP Address' @@ -337,10 +335,10 @@ class IPAddressTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) - role = ChoiceFieldColumn() + role = columns.ChoiceFieldColumn() tenant = TenantColumn() assigned_object = tables.Column( linkify=True, @@ -358,12 +356,12 @@ class IPAddressTable(BaseTable): orderable=False, verbose_name='NAT (Inside)' ) - assigned = BooleanColumn( + assigned = columns.BooleanColumn( accessor='assigned_object_id', linkify=True, verbose_name='Assigned' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) @@ -386,7 +384,7 @@ class IPAddressAssignTable(BaseTable): template_code=IPADDRESS_ASSIGN_LINK, verbose_name='IP Address' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() assigned_object = tables.Column( orderable=False ) @@ -410,7 +408,7 @@ class AssignedIPAddressesTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 5c3e14b2c..8b4f389e6 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -1,7 +1,7 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn from ipam.models import * +from netbox.tables import BaseTable, columns __all__ = ( 'ServiceTable', @@ -10,14 +10,14 @@ __all__ = ( class ServiceTemplateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) ports = tables.Column( accessor=tables.A('port_list') ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:servicetemplate_list' ) @@ -28,7 +28,7 @@ class ServiceTemplateTable(BaseTable): class ServiceTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -39,7 +39,7 @@ class ServiceTable(BaseTable): ports = tables.Column( accessor=tables.A('port_list') ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:service_list' ) diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index d387e24dd..faace1257 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -3,13 +3,10 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from dcim.models import Interface -from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, - TemplateColumn, ToggleColumn, -) -from virtualization.models import VMInterface from ipam.models import * +from netbox.tables import BaseTable, columns +from tenancy.tables import TenantColumn +from virtualization.models import VMInterface __all__ = ( 'InterfaceVLANTable', @@ -62,22 +59,22 @@ VLAN_MEMBER_TAGGED = """ # class VLANGroupTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column(linkify=True) - scope_type = ContentTypeColumn() + scope_type = columns.ContentTypeColumn() scope = tables.Column( linkify=True, orderable=False ) - vlan_count = LinkedCountColumn( + vlan_count = columns.LinkedCountColumn( viewname='ipam:vlan_list', url_params={'group_id': 'pk'}, verbose_name='VLANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vlangroup_list' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=VLANGROUP_BUTTONS ) @@ -95,7 +92,7 @@ class VLANGroupTable(BaseTable): # class VLANTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() vid = tables.TemplateColumn( template_code=VLAN_LINK, verbose_name='VID' @@ -110,18 +107,18 @@ class VLANTable(BaseTable): linkify=True ) tenant = TenantColumn() - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) role = tables.Column( linkify=True ) - prefixes = TemplateColumn( + prefixes = columns.TemplateColumn( template_code=VLAN_PREFIXES, orderable=False, verbose_name='Prefixes' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vlan_list' ) @@ -155,7 +152,7 @@ class VLANDevicesTable(VLANMembersTable): device = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit',) ) @@ -169,7 +166,7 @@ class VLANVirtualMachinesTable(VLANMembersTable): virtual_machine = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit',) ) @@ -187,7 +184,7 @@ class InterfaceVLANTable(BaseTable): linkify=True, verbose_name='ID' ) - tagged = BooleanColumn() + tagged = columns.BooleanColumn() site = tables.Column( linkify=True ) @@ -196,7 +193,7 @@ class InterfaceVLANTable(BaseTable): verbose_name='Group' ) tenant = TenantColumn() - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() role = tables.Column( linkify=True ) diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index e71fb1fa4..7dbad6420 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -1,8 +1,8 @@ import django_tables2 as tables -from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn from ipam.models import * +from netbox.tables import BaseTable, columns +from tenancy.tables import TenantColumn __all__ = ( 'RouteTargetTable', @@ -21,7 +21,7 @@ VRF_TARGETS = """ # class VRFTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -29,18 +29,18 @@ class VRFTable(BaseTable): verbose_name='RD' ) tenant = TenantColumn() - enforce_unique = BooleanColumn( + enforce_unique = columns.BooleanColumn( verbose_name='Unique' ) - import_targets = TemplateColumn( + import_targets = columns.TemplateColumn( template_code=VRF_TARGETS, orderable=False ) - export_targets = TemplateColumn( + export_targets = columns.TemplateColumn( template_code=VRF_TARGETS, orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vrf_list' ) @@ -58,12 +58,12 @@ class VRFTable(BaseTable): # class RouteTargetTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) tenant = TenantColumn() - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vrf_list' ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 35d5cf502..85c2482e5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from dcim.tables import SiteTable from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface diff --git a/netbox/utilities/tables/__init__.py b/netbox/netbox/tables/__init__.py similarity index 91% rename from netbox/utilities/tables/__init__.py rename to netbox/netbox/tables/__init__.py index 25fa95296..40ae2f547 100644 --- a/netbox/utilities/tables/__init__.py +++ b/netbox/netbox/tables/__init__.py @@ -27,13 +27,3 @@ def configure_table(table, request): 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(table) - - -# -# Callables -# - -def linkify_phone(value): - if value is None: - return None - return f"tel:{value}" diff --git a/netbox/utilities/tables/columns.py b/netbox/netbox/tables/columns.py similarity index 100% rename from netbox/utilities/tables/columns.py rename to netbox/netbox/tables/columns.py diff --git a/netbox/utilities/tables/tables.py b/netbox/netbox/tables/tables.py similarity index 99% rename from netbox/utilities/tables/tables.py rename to netbox/netbox/tables/tables.py index d1915569e..8b8f6ae4c 100644 --- a/netbox/utilities/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -7,7 +7,7 @@ from django.db.models.fields.related import RelatedField from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink -from . import columns +from netbox.tables import columns __all__ = ( 'BaseTable', diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 82e1dc217..9c834d76f 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -21,7 +21,7 @@ from utilities.forms import ( ) from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.views import GetReturnURLMixin from .base import BaseMultiObjectView diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 09a102442..316c3a1ee 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -18,7 +18,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin from .base import BaseObjectView diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 55a0591b5..bbaa4fdff 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,9 +1,7 @@ import django_tables2 as tables -from utilities.tables import ( - ActionsColumn, BaseTable, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, - TagColumn, ToggleColumn, -) +from netbox.tables import BaseTable, columns +from utilities.tables import linkify_phone from .models import * __all__ = ( @@ -47,16 +45,16 @@ class TenantColumn(tables.TemplateColumn): # class TenantGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - tenant_count = LinkedCountColumn( + tenant_count = columns.LinkedCountColumn( viewname='tenancy:tenant_list', url_params={'group_id': 'pk'}, verbose_name='Tenants' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='tenancy:tenantgroup_list' ) @@ -69,15 +67,15 @@ class TenantGroupTable(BaseTable): class TenantTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) group = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='tenancy:tenant_list' ) @@ -92,16 +90,16 @@ class TenantTable(BaseTable): # class ContactGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - contact_count = LinkedCountColumn( + contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', url_params={'role_id': 'pk'}, verbose_name='Contacts' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='tenancy:contactgroup_list' ) @@ -114,7 +112,7 @@ class ContactGroupTable(BaseTable): class ContactRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -126,7 +124,7 @@ class ContactRoleTable(BaseTable): class ContactTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -136,11 +134,11 @@ class ContactTable(BaseTable): phone = tables.Column( linkify=linkify_phone, ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() assignment_count = tables.Column( verbose_name='Assignments' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='tenancy:tenant_list' ) @@ -154,8 +152,8 @@ class ContactTable(BaseTable): class ContactAssignmentTable(BaseTable): - pk = ToggleColumn() - content_type = ContentTypeColumn( + pk = columns.ToggleColumn() + content_type = columns.ContentTypeColumn( verbose_name='Object Type' ) object = tables.Column( @@ -168,7 +166,7 @@ class ContactAssignmentTable(BaseTable): role = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 1d2dac0b5..d4ebcb672 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -6,7 +6,7 @@ from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation, Cable from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index 035ca6840..326aac13a 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -6,7 +6,7 @@ from django.urls import reverse from dcim.models import Site from dcim.tables import SiteTable from users.preferences import UserPreference -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.testing import TestCase diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py new file mode 100644 index 000000000..c8420e084 --- /dev/null +++ b/netbox/utilities/tables.py @@ -0,0 +1,7 @@ +def linkify_phone(value): + """ + Render a telephone number as a hyperlink. + """ + if value is None: + return None + return f"tel:{value}" diff --git a/netbox/utilities/tests/test_tables.py b/netbox/utilities/tests/test_tables.py index 55a5e4cc7..7a9f3bd9c 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/utilities/tests/test_tables.py @@ -2,12 +2,12 @@ from django.template import Context, Template from django.test import TestCase from dcim.models import Site -from utilities.tables import BaseTable, TagColumn +from netbox.tables import BaseTable, columns from utilities.testing import create_tags class TagColumnTable(BaseTable): - tags = TagColumn(url_name='dcim:site_list') + tags = columns.TagColumn(url_name='dcim:site_list') class Meta(BaseTable.Meta): model = Site diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 517f0a4b8..950174029 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,11 +1,8 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, -) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface __all__ = ( @@ -31,14 +28,14 @@ VMINTERFACE_BUTTONS = """ # class ClusterTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) cluster_count = tables.Column( verbose_name='Clusters' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:clustertype_list' ) @@ -55,14 +52,14 @@ class ClusterTypeTable(BaseTable): # class ClusterGroupTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) cluster_count = tables.Column( verbose_name='Clusters' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:clustergroup_list' ) @@ -79,7 +76,7 @@ class ClusterGroupTable(BaseTable): # class ClusterTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -95,18 +92,18 @@ class ClusterTable(BaseTable): site = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'cluster_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'cluster_id': 'pk'}, verbose_name='VMs' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='virtualization:cluster_list' ) @@ -124,18 +121,18 @@ class ClusterTable(BaseTable): # class VirtualMachineTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( order_by=('_name',), linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() cluster = tables.Column( linkify=True ) - role = ColoredLabelColumn() + role = columns.ColoredLabelColumn() tenant = TenantColumn() - comments = MarkdownColumn() + comments = columns.MarkdownColumn() primary_ip4 = tables.Column( linkify=True, verbose_name='IPv4 Address' @@ -149,7 +146,7 @@ class VirtualMachineTable(BaseTable): order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:virtualmachine_list' ) @@ -169,14 +166,14 @@ class VirtualMachineTable(BaseTable): # class VMInterfaceTable(BaseInterfaceTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() virtual_machine = tables.Column( linkify=True ) name = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:vminterface_list' ) @@ -196,7 +193,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): bridge = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=VMINTERFACE_BUTTONS ) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0fc8c9bf7..0957e28a2 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,7 +11,7 @@ from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 650d91554..d1dba993d 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Interface -from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn +from netbox.tables import BaseTable, columns from .models import * __all__ = ( @@ -12,16 +12,16 @@ __all__ = ( class WirelessLANGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - wirelesslan_count = LinkedCountColumn( + wirelesslan_count = columns.LinkedCountColumn( viewname='wireless:wirelesslan_list', url_params={'group_id': 'pk'}, verbose_name='Wireless LANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='wireless:wirelesslangroup_list' ) @@ -34,7 +34,7 @@ class WirelessLANGroupTable(BaseTable): class WirelessLANTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() ssid = tables.Column( linkify=True ) @@ -44,7 +44,7 @@ class WirelessLANTable(BaseTable): interface_count = tables.Column( verbose_name='Interfaces' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='wireless:wirelesslan_list' ) @@ -58,7 +58,7 @@ class WirelessLANTable(BaseTable): class WirelessLANInterfacesTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() device = tables.Column( linkify=True ) @@ -73,12 +73,12 @@ class WirelessLANInterfacesTable(BaseTable): class WirelessLinkTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() id = tables.Column( linkify=True, verbose_name='ID' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() device_a = tables.Column( accessor=tables.A('interface_a__device'), linkify=True @@ -93,7 +93,7 @@ class WirelessLinkTable(BaseTable): interface_b = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='wireless:wirelesslink_list' ) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 443cf8eef..5ac90f0be 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,6 +1,6 @@ from dcim.models import Interface from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import * From 59d3f5c4ea6576f60a8beafdec6e3c1d56a47c35 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 15:48:05 -0500 Subject: [PATCH 102/104] Split out NetBoxTable from BaseTable --- netbox/circuits/tables.py | 22 +++--- netbox/dcim/tables/__init__.py | 5 +- netbox/dcim/tables/cables.py | 7 +- netbox/dcim/tables/devices.py | 44 +++++------ netbox/dcim/tables/devicetypes.py | 17 ++-- netbox/dcim/tables/modules.py | 12 ++- netbox/dcim/tables/power.py | 10 +-- netbox/dcim/tables/racks.py | 17 ++-- netbox/dcim/tables/sites.py | 22 +++--- netbox/extras/tables.py | 47 +++++------ netbox/ipam/tables/fhrp.py | 12 ++- netbox/ipam/tables/ip.py | 45 +++++------ netbox/ipam/tables/services.py | 12 ++- netbox/ipam/tables/vlans.py | 22 +++--- netbox/ipam/tables/vrfs.py | 12 ++- netbox/netbox/tables/tables.py | 79 +++++++++++++------ .../tests/test_tables.py | 6 +- netbox/tenancy/tables.py | 32 +++----- netbox/virtualization/tables.py | 27 +++---- netbox/wireless/tables.py | 22 +++--- 20 files changed, 218 insertions(+), 254 deletions(-) rename netbox/{utilities => netbox}/tests/test_tables.py (89%) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 0ffb8f03b..56da24842 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .models import * @@ -47,8 +47,7 @@ class CommitRateColumn(tables.TemplateColumn): # Providers # -class ProviderTable(BaseTable): - pk = columns.ToggleColumn() +class ProviderTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -61,7 +60,7 @@ class ProviderTable(BaseTable): url_name='circuits:provider_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Provider fields = ( 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', @@ -74,8 +73,7 @@ class ProviderTable(BaseTable): # Provider networks # -class ProviderNetworkTable(BaseTable): - pk = columns.ToggleColumn() +class ProviderNetworkTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -87,7 +85,7 @@ class ProviderNetworkTable(BaseTable): url_name='circuits:providernetwork_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ProviderNetwork fields = ( 'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags', @@ -99,8 +97,7 @@ class ProviderNetworkTable(BaseTable): # Circuit types # -class CircuitTypeTable(BaseTable): - pk = columns.ToggleColumn() +class CircuitTypeTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -111,7 +108,7 @@ class CircuitTypeTable(BaseTable): verbose_name='Circuits' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CircuitType fields = ( 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -123,8 +120,7 @@ class CircuitTypeTable(BaseTable): # Circuits # -class CircuitTable(BaseTable): - pk = columns.ToggleColumn() +class CircuitTable(NetBoxTable): cid = tables.Column( linkify=True, verbose_name='Circuit ID' @@ -148,7 +144,7 @@ class CircuitTable(BaseTable): url_name='circuits:circuit_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 7567762fa..e3b2a42ba 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,8 +1,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import ConsolePort, Interface, PowerPort from netbox.tables import BaseTable, columns +from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * @@ -44,7 +44,6 @@ class ConsoleConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = ConsolePort fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') - exclude = ('id', ) class PowerConnectionTable(BaseTable): @@ -75,7 +74,6 @@ class PowerConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPort fields = ('device', 'name', 'pdu', 'outlet', 'reachable') - exclude = ('id', ) class InterfaceConnectionTable(BaseTable): @@ -109,4 +107,3 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') - exclude = ('id', ) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index addb67c33..1774a3e22 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT @@ -15,8 +15,7 @@ __all__ = ( # Cables # -class CableTable(BaseTable): - pk = columns.ToggleColumn() +class CableTable(NetBoxTable): termination_a_parent = tables.TemplateColumn( template_code=CABLE_TERMINATION_PARENT, accessor=Accessor('termination_a'), @@ -52,7 +51,7 @@ class CableTable(BaseTable): url_name='dcim:cable_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7dee2bcbe..37faaae7f 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -5,7 +5,7 @@ from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .template_code import * @@ -71,8 +71,7 @@ def get_interface_state_attribute(record): # Device roles # -class DeviceRoleTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceRoleTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -92,7 +91,7 @@ class DeviceRoleTable(BaseTable): url_name='dcim:devicerole_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', @@ -105,8 +104,7 @@ class DeviceRoleTable(BaseTable): # Platforms # -class PlatformTable(BaseTable): - pk = columns.ToggleColumn() +class PlatformTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -124,7 +122,7 @@ class PlatformTable(BaseTable): url_name='dcim:platform_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', @@ -139,8 +137,7 @@ class PlatformTable(BaseTable): # Devices # -class DeviceTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceTable(NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK @@ -197,7 +194,7 @@ class DeviceTable(BaseTable): url_name='dcim:device_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', @@ -211,7 +208,7 @@ class DeviceTable(BaseTable): ) -class DeviceImportTable(BaseTable): +class DeviceImportTable(NetBoxTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) @@ -230,7 +227,7 @@ class DeviceImportTable(BaseTable): verbose_name='Type' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False @@ -240,8 +237,7 @@ class DeviceImportTable(BaseTable): # Device components # -class DeviceComponentTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceComponentTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -250,7 +246,7 @@ class DeviceComponentTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): order_by = ('device', 'name') @@ -267,7 +263,7 @@ class ModularDeviceComponentTable(DeviceComponentTable): ) -class CableTerminationTable(BaseTable): +class CableTerminationTable(NetBoxTable): cable = tables.Column( linkify=True ) @@ -473,7 +469,7 @@ class DevicePowerOutletTable(PowerOutletTable): } -class BaseInterfaceTable(BaseTable): +class BaseInterfaceTable(NetBoxTable): enabled = columns.BooleanColumn() ip_addresses = tables.TemplateColumn( template_code=INTERFACE_IPADDRESSES, @@ -776,7 +772,7 @@ class InventoryItemTable(DeviceComponentTable): ) cable = None # Override DeviceComponentTable - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', @@ -796,7 +792,7 @@ class DeviceInventoryItemTable(InventoryItemTable): ) actions = columns.ActionsColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', @@ -807,8 +803,7 @@ class DeviceInventoryItemTable(InventoryItemTable): ) -class InventoryItemRoleTable(BaseTable): - pk = columns.ToggleColumn() +class InventoryItemRoleTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -822,7 +817,7 @@ class InventoryItemRoleTable(BaseTable): url_name='dcim:inventoryitemrole_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', @@ -834,8 +829,7 @@ class InventoryItemRoleTable(BaseTable): # Virtual chassis # -class VirtualChassisTable(BaseTable): - pk = columns.ToggleColumn() +class VirtualChassisTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -851,7 +845,7 @@ class VirtualChassisTable(BaseTable): url_name='dcim:virtualchassis_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VirtualChassis fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 0b4f04a2a..44848f6ba 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -5,7 +5,7 @@ from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( @@ -28,8 +28,7 @@ __all__ = ( # Manufacturers # -class ManufacturerTable(BaseTable): - pk = columns.ToggleColumn() +class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -47,7 +46,7 @@ class ManufacturerTable(BaseTable): url_name='dcim:manufacturer_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', @@ -62,8 +61,7 @@ class ManufacturerTable(BaseTable): # Device types # -class DeviceTypeTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Device Type' @@ -81,7 +79,7 @@ class DeviceTypeTable(BaseTable): url_name='dcim:devicetype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', @@ -96,8 +94,7 @@ class DeviceTypeTable(BaseTable): # Device type components # -class ComponentTemplateTable(BaseTable): - pk = columns.ToggleColumn() +class ComponentTemplateTable(NetBoxTable): id = tables.Column( verbose_name='ID' ) @@ -105,7 +102,7 @@ class ComponentTemplateTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): exclude = ('id', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 4a4c9d09a..5b009e42e 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Module, ModuleType -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns __all__ = ( 'ModuleTable', @@ -9,8 +9,7 @@ __all__ = ( ) -class ModuleTypeTable(BaseTable): - pk = columns.ToggleColumn() +class ModuleTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Module Type' @@ -25,7 +24,7 @@ class ModuleTypeTable(BaseTable): url_name='dcim:moduletype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ModuleType fields = ( 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', @@ -35,8 +34,7 @@ class ModuleTypeTable(BaseTable): ) -class ModuleTable(BaseTable): - pk = columns.ToggleColumn() +class ModuleTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -51,7 +49,7 @@ class ModuleTable(BaseTable): url_name='dcim:module_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Module fields = ( 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index e1c0304a2..99bc963f9 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import PowerFeed, PowerPanel -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .devices import CableTerminationTable __all__ = ( @@ -14,8 +14,7 @@ __all__ = ( # Power panels # -class PowerPanelTable(BaseTable): - pk = columns.ToggleColumn() +class PowerPanelTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -31,7 +30,7 @@ class PowerPanelTable(BaseTable): url_name='dcim:powerpanel_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerPanel fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -44,7 +43,6 @@ class PowerPanelTable(BaseTable): # We're not using PathEndpointTable for PowerFeed because power connections # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): - pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -67,7 +65,7 @@ class PowerFeedTable(CableTerminationTable): url_name='dcim:powerfeed_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerFeed fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 9e89d7b82..416e9e8ff 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn __all__ = ( @@ -16,8 +16,7 @@ __all__ = ( # Rack roles # -class RackRoleTable(BaseTable): - pk = columns.ToggleColumn() +class RackRoleTable(NetBoxTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') color = columns.ColorColumn() @@ -25,7 +24,7 @@ class RackRoleTable(BaseTable): url_name='dcim:rackrole_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackRole fields = ( 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created', @@ -38,8 +37,7 @@ class RackRoleTable(BaseTable): # Racks # -class RackTable(BaseTable): - pk = columns.ToggleColumn() +class RackTable(NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -83,7 +81,7 @@ class RackTable(BaseTable): verbose_name='Outer Depth' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', @@ -100,8 +98,7 @@ class RackTable(BaseTable): # Rack reservations # -class RackReservationTable(BaseTable): - pk = columns.ToggleColumn() +class RackReservationTable(NetBoxTable): reservation = tables.Column( accessor='pk', linkify=True @@ -122,7 +119,7 @@ class RackReservationTable(BaseTable): url_name='dcim:rackreservation_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackReservation fields = ( 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 7a4e2f34f..1be1f74d0 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .template_code import LOCATION_BUTTONS @@ -17,8 +17,7 @@ __all__ = ( # Regions # -class RegionTable(BaseTable): - pk = columns.ToggleColumn() +class RegionTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -31,7 +30,7 @@ class RegionTable(BaseTable): url_name='dcim:region_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Region fields = ( 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', @@ -43,8 +42,7 @@ class RegionTable(BaseTable): # Site groups # -class SiteGroupTable(BaseTable): - pk = columns.ToggleColumn() +class SiteGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -57,7 +55,7 @@ class SiteGroupTable(BaseTable): url_name='dcim:sitegroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = SiteGroup fields = ( 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', @@ -69,8 +67,7 @@ class SiteGroupTable(BaseTable): # Sites # -class SiteTable(BaseTable): - pk = columns.ToggleColumn() +class SiteTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -93,7 +90,7 @@ class SiteTable(BaseTable): url_name='dcim:site_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Site fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', @@ -107,8 +104,7 @@ class SiteTable(BaseTable): # Locations # -class LocationTable(BaseTable): - pk = columns.ToggleColumn() +class LocationTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -133,7 +129,7 @@ class LocationTable(BaseTable): extra_buttons=LOCATION_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Location fields = ( 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index b235cd8e2..52aeb9708 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django.conf import settings -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .models import * __all__ = ( @@ -43,15 +43,14 @@ OBJECTCHANGE_REQUEST_ID = """ # Custom fields # -class CustomFieldTable(BaseTable): - pk = columns.ToggleColumn() +class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True ) content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', @@ -64,8 +63,7 @@ class CustomFieldTable(BaseTable): # Custom links # -class CustomLinkTable(BaseTable): - pk = columns.ToggleColumn() +class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -73,7 +71,7 @@ class CustomLinkTable(BaseTable): enabled = columns.BooleanColumn() new_window = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CustomLink fields = ( 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', @@ -86,15 +84,14 @@ class CustomLinkTable(BaseTable): # Export templates # -class ExportTemplateTable(BaseTable): - pk = columns.ToggleColumn() +class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True ) content_type = columns.ContentTypeColumn() as_attachment = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', @@ -109,8 +106,7 @@ class ExportTemplateTable(BaseTable): # Webhooks # -class WebhookTable(BaseTable): - pk = columns.ToggleColumn() +class WebhookTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -129,7 +125,7 @@ class WebhookTable(BaseTable): verbose_name='SSL Validation' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Webhook fields = ( 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', @@ -145,20 +141,19 @@ class WebhookTable(BaseTable): # Tags # -class TagTable(BaseTable): - pk = columns.ToggleColumn() +class TagTable(NetBoxTable): name = tables.Column( linkify=True ) color = columns.ColorColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Tag fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') -class TaggedItemTable(BaseTable): +class TaggedItemTable(NetBoxTable): id = tables.Column( verbose_name='ID', linkify=lambda record: record.content_object.get_absolute_url(), @@ -173,13 +168,12 @@ class TaggedItemTable(BaseTable): verbose_name='Object' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = TaggedItem fields = ('id', 'content_type', 'content_object') -class ConfigContextTable(BaseTable): - pk = columns.ToggleColumn() +class ConfigContextTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -187,7 +181,7 @@ class ConfigContextTable(BaseTable): verbose_name='Active' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', @@ -197,7 +191,7 @@ class ConfigContextTable(BaseTable): default_columns = ('pk', 'name', 'weight', 'is_active', 'description') -class ObjectChangeTable(BaseTable): +class ObjectChangeTable(NetBoxTable): time = tables.DateTimeColumn( linkify=True, format=settings.SHORT_DATETIME_FORMAT @@ -216,12 +210,12 @@ class ObjectChangeTable(BaseTable): ) actions = columns.ActionsColumn(sequence=()) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ObjectChange fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') -class ObjectJournalTable(BaseTable): +class ObjectJournalTable(NetBoxTable): """ Used for displaying a set of JournalEntries within the context of a single object. """ @@ -234,13 +228,12 @@ class ObjectJournalTable(BaseTable): template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = JournalEntry fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions') class JournalEntryTable(ObjectJournalTable): - pk = columns.ToggleColumn() assigned_object_type = columns.ContentTypeColumn( verbose_name='Object type' ) @@ -251,7 +244,7 @@ class JournalEntryTable(ObjectJournalTable): ) comments = columns.MarkdownColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = JournalEntry fields = ( 'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index e200d6cac..8848cb079 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,7 +1,7 @@ import django_tables2 as tables from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns __all__ = ( 'FHRPGroupTable', @@ -16,8 +16,7 @@ IPADDRESSES = """ """ -class FHRPGroupTable(BaseTable): - pk = columns.ToggleColumn() +class FHRPGroupTable(NetBoxTable): group_id = tables.Column( linkify=True ) @@ -34,7 +33,7 @@ class FHRPGroupTable(BaseTable): url_name='ipam:fhrpgroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', @@ -43,8 +42,7 @@ class FHRPGroupTable(BaseTable): default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') -class FHRPGroupAssignmentTable(BaseTable): - pk = columns.ToggleColumn() +class FHRPGroupAssignmentTable(NetBoxTable): interface_parent = tables.Column( accessor=tables.A('interface.parent_object'), linkify=True, @@ -62,7 +60,7 @@ class FHRPGroupAssignmentTable(BaseTable): sequence=('edit', 'delete') ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = FHRPGroupAssignment fields = ('pk', 'group', 'interface_parent', 'interface', 'priority') exclude = ('id',) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index a69118da3..762857136 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -3,7 +3,7 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn __all__ = ( @@ -70,8 +70,7 @@ VRF_LINK = """ # RIRs # -class RIRTable(BaseTable): - pk = columns.ToggleColumn() +class RIRTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -87,7 +86,7 @@ class RIRTable(BaseTable): url_name='ipam:rir_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RIR fields = ( 'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'created', @@ -100,8 +99,7 @@ class RIRTable(BaseTable): # ASNs # -class ASNTable(BaseTable): - pk = columns.ToggleColumn() +class ASNTable(NetBoxTable): asn = tables.Column( accessor=tables.A('asn_asdot'), linkify=True @@ -113,7 +111,7 @@ class ASNTable(BaseTable): verbose_name='Sites' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ASN fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant') @@ -123,8 +121,7 @@ class ASNTable(BaseTable): # Aggregates # -class AggregateTable(BaseTable): - pk = columns.ToggleColumn() +class AggregateTable(NetBoxTable): prefix = tables.Column( linkify=True, verbose_name='Aggregate' @@ -145,7 +142,7 @@ class AggregateTable(BaseTable): url_name='ipam:aggregate_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Aggregate fields = ( 'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags', @@ -158,8 +155,7 @@ class AggregateTable(BaseTable): # Roles # -class RoleTable(BaseTable): - pk = columns.ToggleColumn() +class RoleTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -177,7 +173,7 @@ class RoleTable(BaseTable): url_name='ipam:role_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Role fields = ( 'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'created', @@ -205,8 +201,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): """ -class PrefixTable(BaseTable): - pk = columns.ToggleColumn() +class PrefixTable(NetBoxTable): prefix = tables.TemplateColumn( template_code=PREFIX_LINK, attrs={'td': {'class': 'text-nowrap'}} @@ -266,7 +261,7 @@ class PrefixTable(BaseTable): url_name='ipam:prefix_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Prefix fields = ( 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', @@ -283,8 +278,7 @@ class PrefixTable(BaseTable): # # IP ranges # -class IPRangeTable(BaseTable): - pk = columns.ToggleColumn() +class IPRangeTable(NetBoxTable): start_address = tables.Column( linkify=True ) @@ -307,7 +301,7 @@ class IPRangeTable(BaseTable): url_name='ipam:iprange_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPRange fields = ( 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -325,8 +319,7 @@ class IPRangeTable(BaseTable): # IPAddresses # -class IPAddressTable(BaseTable): - pk = columns.ToggleColumn() +class IPAddressTable(NetBoxTable): address = tables.TemplateColumn( template_code=IPADDRESS_LINK, verbose_name='IP Address' @@ -365,7 +358,7 @@ class IPAddressTable(BaseTable): url_name='ipam:ipaddress_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ( 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', @@ -379,7 +372,7 @@ class IPAddressTable(BaseTable): } -class IPAddressAssignTable(BaseTable): +class IPAddressAssignTable(NetBoxTable): address = tables.TemplateColumn( template_code=IPADDRESS_ASSIGN_LINK, verbose_name='IP Address' @@ -389,14 +382,14 @@ class IPAddressAssignTable(BaseTable): orderable=False ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description') exclude = ('id', ) orderable = False -class AssignedIPAddressesTable(BaseTable): +class AssignedIPAddressesTable(NetBoxTable): """ List IP addresses assigned to an object. """ @@ -411,7 +404,7 @@ class AssignedIPAddressesTable(BaseTable): status = columns.ChoiceFieldColumn() tenant = TenantColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description') exclude = ('id', ) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8b4f389e6..8c81a28c2 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -1,7 +1,7 @@ import django_tables2 as tables from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns __all__ = ( 'ServiceTable', @@ -9,8 +9,7 @@ __all__ = ( ) -class ServiceTemplateTable(BaseTable): - pk = columns.ToggleColumn() +class ServiceTemplateTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -21,14 +20,13 @@ class ServiceTemplateTable(BaseTable): url_name='ipam:servicetemplate_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ServiceTemplate fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags') default_columns = ('pk', 'name', 'protocol', 'ports', 'description') -class ServiceTable(BaseTable): - pk = columns.ToggleColumn() +class ServiceTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -43,7 +41,7 @@ class ServiceTable(BaseTable): url_name='ipam:service_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Service fields = ( 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index faace1257..192da0813 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -4,7 +4,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from virtualization.models import VMInterface @@ -58,8 +58,7 @@ VLAN_MEMBER_TAGGED = """ # VLAN groups # -class VLANGroupTable(BaseTable): - pk = columns.ToggleColumn() +class VLANGroupTable(NetBoxTable): name = tables.Column(linkify=True) scope_type = columns.ContentTypeColumn() scope = tables.Column( @@ -78,7 +77,7 @@ class VLANGroupTable(BaseTable): extra_buttons=VLANGROUP_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', @@ -91,8 +90,7 @@ class VLANGroupTable(BaseTable): # VLANs # -class VLANTable(BaseTable): - pk = columns.ToggleColumn() +class VLANTable(NetBoxTable): vid = tables.TemplateColumn( template_code=VLAN_LINK, verbose_name='VID' @@ -122,7 +120,7 @@ class VLANTable(BaseTable): url_name='ipam:vlan_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLAN fields = ( 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags', @@ -134,7 +132,7 @@ class VLANTable(BaseTable): } -class VLANMembersTable(BaseTable): +class VLANMembersTable(NetBoxTable): """ Base table for Interface and VMInterface assignments """ @@ -156,7 +154,7 @@ class VLANDevicesTable(VLANMembersTable): sequence=('edit',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Interface fields = ('device', 'name', 'tagged', 'actions') exclude = ('id', ) @@ -170,13 +168,13 @@ class VLANVirtualMachinesTable(VLANMembersTable): sequence=('edit',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VMInterface fields = ('virtual_machine', 'name', 'tagged', 'actions') exclude = ('id', ) -class InterfaceVLANTable(BaseTable): +class InterfaceVLANTable(NetBoxTable): """ List VLANs assigned to a specific Interface. """ @@ -198,7 +196,7 @@ class InterfaceVLANTable(BaseTable): linkify=True ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLAN fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') exclude = ('id', ) diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 7dbad6420..727f402ff 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -1,7 +1,7 @@ import django_tables2 as tables from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn __all__ = ( @@ -20,8 +20,7 @@ VRF_TARGETS = """ # VRFs # -class VRFTable(BaseTable): - pk = columns.ToggleColumn() +class VRFTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -44,7 +43,7 @@ class VRFTable(BaseTable): url_name='ipam:vrf_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VRF fields = ( 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', @@ -57,8 +56,7 @@ class VRFTable(BaseTable): # Route targets # -class RouteTargetTable(BaseTable): - pk = columns.ToggleColumn() +class RouteTargetTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -67,7 +65,7 @@ class RouteTargetTable(BaseTable): url_name='ipam:vrf_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RouteTarget fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8b8f6ae4c..fe422118c 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -11,49 +11,37 @@ from netbox.tables import columns __all__ = ( 'BaseTable', + 'NetBoxTable', ) class BaseTable(tables.Table): """ - Default table for object lists + Base table class for NetBox objects. Adds support for: + + * User configuration (column preferences) + * Automatic prefetching of related objects + * BS5 styling :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. """ - id = tables.Column( - linkify=True, - verbose_name='ID' - ) - actions = columns.ActionsColumn() + exempt_columns = () class Meta: attrs = { 'class': 'table table-hover object-list', } - def __init__(self, *args, user=None, extra_columns=None, **kwargs): - if extra_columns is None: - extra_columns = [] + def __init__(self, *args, user=None, **kwargs): - # Add custom field & custom link columns - content_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(content_types=content_type) - extra_columns.extend([ - (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields - ]) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) - extra_columns.extend([ - (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links - ]) - - super().__init__(*args, extra_columns=extra_columns, **kwargs) + super().__init__(*args, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" - # Hide non-default columns (except for actions) - default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), 'actions'] + # Hide non-default columns + default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), *self.exempt_columns] for column in self.columns: if column.name not in default_columns: self.columns.hide(column.name) @@ -65,7 +53,7 @@ class BaseTable(tables.Table): # Show only persistent or selected columns for name, column in self.columns.items(): - if name in ['pk', 'actions', *selected_columns]: + if name in [*self.exempt_columns, *selected_columns]: self.columns.show(name) else: self.columns.hide(name) @@ -116,7 +104,7 @@ class BaseTable(tables.Table): def _get_columns(self, visible=True): columns = [] for name, column in self.columns.items(): - if column.visible == visible and name not in ['pk', 'actions']: + if column.visible == visible and name not in self.exempt_columns: columns.append((name, column.verbose_name)) return columns @@ -137,3 +125,44 @@ class BaseTable(tables.Table): if not hasattr(self, '_objects_count'): self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) return self._objects_count + + +class NetBoxTable(BaseTable): + """ + Table class for most NetBox objects. Adds support for custom field & custom link columns. Includes + default columns for: + + * PK (row selection) + * ID + * Actions + """ + pk = columns.ToggleColumn( + visible=False + ) + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + actions = columns.ActionsColumn() + + exempt_columns = ('pk', 'actions') + + class Meta(BaseTable.Meta): + pass + + def __init__(self, *args, extra_columns=None, **kwargs): + if extra_columns is None: + extra_columns = [] + + # Add custom field & custom link columns + content_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = CustomField.objects.filter(content_types=content_type) + extra_columns.extend([ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields + ]) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + extra_columns.extend([ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links + ]) + + super().__init__(*args, extra_columns=extra_columns, **kwargs) diff --git a/netbox/utilities/tests/test_tables.py b/netbox/netbox/tests/test_tables.py similarity index 89% rename from netbox/utilities/tests/test_tables.py rename to netbox/netbox/tests/test_tables.py index 7a9f3bd9c..17b9743cd 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/netbox/tests/test_tables.py @@ -2,14 +2,14 @@ from django.template import Context, Template from django.test import TestCase from dcim.models import Site -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from utilities.testing import create_tags -class TagColumnTable(BaseTable): +class TagColumnTable(NetBoxTable): tags = columns.TagColumn(url_name='dcim:site_list') - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Site fields = ('pk', 'name', 'tags',) default_columns = fields diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index bbaa4fdff..4f90ee01f 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from utilities.tables import linkify_phone from .models import * @@ -44,8 +44,7 @@ class TenantColumn(tables.TemplateColumn): # Tenants # -class TenantGroupTable(BaseTable): - pk = columns.ToggleColumn() +class TenantGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -58,7 +57,7 @@ class TenantGroupTable(BaseTable): url_name='tenancy:tenantgroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = TenantGroup fields = ( 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -66,8 +65,7 @@ class TenantGroupTable(BaseTable): default_columns = ('pk', 'name', 'tenant_count', 'description') -class TenantTable(BaseTable): - pk = columns.ToggleColumn() +class TenantTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -79,7 +77,7 @@ class TenantTable(BaseTable): url_name='tenancy:tenant_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Tenant fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'group', 'description') @@ -89,8 +87,7 @@ class TenantTable(BaseTable): # Contacts # -class ContactGroupTable(BaseTable): - pk = columns.ToggleColumn() +class ContactGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -103,7 +100,7 @@ class ContactGroupTable(BaseTable): url_name='tenancy:contactgroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ContactGroup fields = ( 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -111,20 +108,18 @@ class ContactGroupTable(BaseTable): default_columns = ('pk', 'name', 'contact_count', 'description') -class ContactRoleTable(BaseTable): - pk = columns.ToggleColumn() +class ContactRoleTable(NetBoxTable): name = tables.Column( linkify=True ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ContactRole fields = ('pk', 'name', 'description', 'slug', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'description') -class ContactTable(BaseTable): - pk = columns.ToggleColumn() +class ContactTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -142,7 +137,7 @@ class ContactTable(BaseTable): url_name='tenancy:tenant_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Contact fields = ( 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags', @@ -151,8 +146,7 @@ class ContactTable(BaseTable): default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') -class ContactAssignmentTable(BaseTable): - pk = columns.ToggleColumn() +class ContactAssignmentTable(NetBoxTable): content_type = columns.ContentTypeColumn( verbose_name='Object Type' ) @@ -170,7 +164,7 @@ class ContactAssignmentTable(BaseTable): sequence=('edit', 'delete') ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ContactAssignment fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 950174029..e1156627a 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -27,8 +27,7 @@ VMINTERFACE_BUTTONS = """ # Cluster types # -class ClusterTypeTable(BaseTable): - pk = columns.ToggleColumn() +class ClusterTypeTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -39,7 +38,7 @@ class ClusterTypeTable(BaseTable): url_name='virtualization:clustertype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ClusterType fields = ( 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'created', 'last_updated', 'tags', 'actions', @@ -51,8 +50,7 @@ class ClusterTypeTable(BaseTable): # Cluster groups # -class ClusterGroupTable(BaseTable): - pk = columns.ToggleColumn() +class ClusterGroupTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -63,7 +61,7 @@ class ClusterGroupTable(BaseTable): url_name='virtualization:clustergroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ClusterGroup fields = ( 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'created', 'last_updated', 'actions', @@ -75,8 +73,7 @@ class ClusterGroupTable(BaseTable): # Clusters # -class ClusterTable(BaseTable): - pk = columns.ToggleColumn() +class ClusterTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -107,7 +104,7 @@ class ClusterTable(BaseTable): url_name='virtualization:cluster_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Cluster fields = ( 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags', @@ -120,8 +117,7 @@ class ClusterTable(BaseTable): # Virtual machines # -class VirtualMachineTable(BaseTable): - pk = columns.ToggleColumn() +class VirtualMachineTable(NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -150,7 +146,7 @@ class VirtualMachineTable(BaseTable): url_name='virtualization:virtualmachine_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', @@ -166,7 +162,6 @@ class VirtualMachineTable(BaseTable): # class VMInterfaceTable(BaseInterfaceTable): - pk = columns.ToggleColumn() virtual_machine = tables.Column( linkify=True ) @@ -177,7 +172,7 @@ class VMInterfaceTable(BaseInterfaceTable): url_name='virtualization:vminterface_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', @@ -198,7 +193,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): extra_buttons=VMINTERFACE_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VMInterface fields = ( 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index d1dba993d..8dd81dffd 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Interface -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .models import * __all__ = ( @@ -11,8 +11,7 @@ __all__ = ( ) -class WirelessLANGroupTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLANGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -25,7 +24,7 @@ class WirelessLANGroupTable(BaseTable): url_name='wireless:wirelesslangroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = WirelessLANGroup fields = ( 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -33,8 +32,7 @@ class WirelessLANGroupTable(BaseTable): default_columns = ('pk', 'name', 'wirelesslan_count', 'description') -class WirelessLANTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLANTable(NetBoxTable): ssid = tables.Column( linkify=True ) @@ -48,7 +46,7 @@ class WirelessLANTable(BaseTable): url_name='wireless:wirelesslan_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', @@ -57,8 +55,7 @@ class WirelessLANTable(BaseTable): default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') -class WirelessLANInterfacesTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLANInterfacesTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -66,14 +63,13 @@ class WirelessLANInterfacesTable(BaseTable): linkify=True ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Interface fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel') default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel') -class WirelessLinkTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLinkTable(NetBoxTable): id = tables.Column( linkify=True, verbose_name='ID' @@ -97,7 +93,7 @@ class WirelessLinkTable(BaseTable): url_name='wireless:wirelesslink_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = WirelessLink fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', From 3c1ea5d0fb2fabeda539e1cdc23e62bcd15b7b8d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 16:14:02 -0500 Subject: [PATCH 103/104] Closes #8470: Expose NetBoxTable in the plugins framework --- docs/plugins/development/tables.md | 37 ++++++++++++++++++++++++++++++ mkdocs.yml | 5 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 docs/plugins/development/tables.md diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md new file mode 100644 index 000000000..16f9f6c17 --- /dev/null +++ b/docs/plugins/development/tables.md @@ -0,0 +1,37 @@ +# Tables + +NetBox employs the [`django-tables2`](https://django-tables2.readthedocs.io/) library for rendering dynamic object tables. These tables display lists of objects, and can be sorted and filtered by various parameters. + +## NetBoxTable + +To provide additional functionality beyond what is supported by the stock `Table` class in `django-tables2`, NetBox provides the `NetBoxTable` class. This custom table class includes support for: + +* User-configurable column display and ordering +* Custom field & custom link columns +* Automatic prefetching of related objects + +It also includes several default columns: + +* `pk` - A checkbox for selecting the object associated with each table row +* `id` - The object's numeric database ID, as a hyperlink to the object's view +* `actions` - A dropdown menu presenting object-specific actions available to the user. + +### Example + +```python +# tables.py +import django_tables2 as tables +from netbox.tables import NetBoxTable +from .models import MyModel + +class MyModelTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + ... + + class Meta(NetBoxTable.Meta): + model = MyModel + fields = ('pk', 'id', 'name', ...) + default_columns = ('pk', 'name', ...) +``` diff --git a/mkdocs.yml b/mkdocs.yml index 1a77cb195..3b1e52f50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -102,9 +102,10 @@ nav: - Using Plugins: 'plugins/index.md' - Developing Plugins: - Getting Started: 'plugins/development/index.md' - - Database Models: 'plugins/development/models.md' + - Models: 'plugins/development/models.md' - Views: 'plugins/development/views.md' - - Filtersets: 'plugins/development/filtersets.md' + - Tables: 'plugins/development/tables.md' + - Filter Sets: 'plugins/development/filtersets.md' - REST API: 'plugins/development/rest-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Administration: From f1697c68566e5573ebb39848b1a2dd5bbadc9636 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 16:21:19 -0500 Subject: [PATCH 104/104] Add change log for plugins framework additions --- docs/release-notes/version-3.2.md | 38 +++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 789003cca..1b4a7ef87 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,23 +14,15 @@ ### New Features -#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) +#### Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) -A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. +NetBox's plugins framework has been extended considerably in this release. Changes include: -#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) +* Seven generic view classes are now officially supported for use by plugins. +* `NetBoxModel` is available for subclassing to enable various NetBox features, such as custom fields and change logging. +* `NetBoxModelFilterSet` is available to extend NetBox's dynamic filtering ability to plugin models. -A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. - -#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) - -A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. - -#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) - -Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. - -Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object. +No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized. #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) @@ -38,6 +30,12 @@ Several new models have been added to support field-replaceable device modules, Automatic renaming of module components is also supported. When a new module is created, any occurrence of the string `{module}` in a component name will be replaced with the position of the module bay into which the module is being installed. +#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) + +Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. + +Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object. + #### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) Custom choices can be now added to most status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field: @@ -52,12 +50,24 @@ FIELD_CHOICES = { } ``` +#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) + +A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. + #### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) Inventory items can now be templatized on a device type similar to the other component types. This enables users to better pre-model fixed hardware components. Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other components. These relationships will be mirrored when instantiating inventory items on a newly-created device. +#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) + +A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. + +#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) + +A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. + ### Enhancements * [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators
    AS Number{{ object.asn }}{{ object.asn_with_asdot }}
    RIR