From e11e8a5d6436f770e8157bdbd20123e2878a5c7b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 09:15:25 -0500 Subject: [PATCH 01/70] 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 02/70] 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 03/70] 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 04/70] 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 05/70] 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 0a22b3990fe93c27f19dfe1797de7ce07ffd109f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 20:42:44 -0500 Subject: [PATCH 06/70] #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 10/70] 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 11/70] 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 12/70] 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 30/70] 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 31/70] 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 32/70] 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 f66a265fcf2eff66e05ceb6237add43a23ab3668 Mon Sep 17 00:00:00 2001 From: Jason Yates Date: Sat, 8 Jan 2022 21:55:07 +0000 Subject: [PATCH 33/70] 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 34/70] 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 35/70] 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 36/70] 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 37/70] 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 38/70] 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 39/70] 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 40/70] 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 41/70] 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 42/70] 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 9152ba72f1ae2a4602a83558d1b8a77452f8cb59 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 14:44:25 -0500 Subject: [PATCH 43/70] 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 55/70] 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 56/70] 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 57/70] 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 58/70] 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 59/70] 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 60/70] 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 61/70] 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 62/70] 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 63/70] 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 64/70] 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 65/70] 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 66/70] 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 67/70] 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 68/70] 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 69/70] 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 70/70] 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
    AS Number{{ object.asn }}{{ object.asn_with_asdot }}
    RIR