From 6cda55da0660eedfa301d91746f1dcc020364ead Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 09:41:35 -0500 Subject: [PATCH 01/20] Fixes #8187: Fix rendering of tags column in object tables --- docs/release-notes/version-3.1.md | 4 +++ netbox/utilities/tables.py | 3 ++- netbox/utilities/tests/test_tables.py | 36 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 netbox/utilities/tests/test_tables.py diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 61da98952..25b0a1353 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,6 +2,10 @@ ## v3.1.4 (FUTURE) +### Bug Fixes + +* [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables + --- ## v3.1.3 (2021-12-29) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 183d64023..9000af110 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -381,8 +381,9 @@ class TagColumn(tables.TemplateColumn): Display a list of tags assigned to the object. """ template_code = """ + {% load helpers %} {% for tag in value.all %} - {% include 'utilities/templatetags/tag.html' %} + {% tag tag url_name=url_name %} {% empty %} {% endfor %} diff --git a/netbox/utilities/tests/test_tables.py b/netbox/utilities/tests/test_tables.py new file mode 100644 index 000000000..119587ff8 --- /dev/null +++ b/netbox/utilities/tests/test_tables.py @@ -0,0 +1,36 @@ +from django.template import Context, Template +from django.test import TestCase + +from dcim.models import Site +from utilities.tables import BaseTable, TagColumn +from utilities.testing import create_tags + + +class TagColumnTable(BaseTable): + tags = TagColumn(url_name='dcim:site_list') + + class Meta(BaseTable.Meta): + model = Site + fields = ('pk', 'name', 'tags',) + default_columns = fields + + +class TagColumnTest(TestCase): + + @classmethod + def setUpTestData(cls): + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + sites = [ + Site(name=f'Site {i}', slug=f'site-{i}') for i in range(1, 6) + ] + Site.objects.bulk_create(sites) + for site in sites: + site.tags.add(*tags) + + def test_tagcolumn(self): + template = Template('{% load render_table from django_tables2 %}{% render_table table %}') + context = Context({ + 'table': TagColumnTable(Site.objects.all(), orderable=False) + }) + template.render(context) From a5f1707662b56a60404bc5b79681733e72bca0ef Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 09:46:02 -0500 Subject: [PATCH 02/20] Fixes #8191: Fix return URL when adding IP addresses to VM interfaces --- docs/release-notes/version-3.1.md | 1 + netbox/virtualization/tables.py | 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 25b0a1353..f10459b96 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -4,6 +4,7 @@ ### Bug Fixes +* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces * [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables --- diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index b07259e5c..818b09d33 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -18,7 +18,7 @@ __all__ = ( VMINTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %} - + {% endif %} From 2319fce09230af41e85ded80f4b1366148b2ea95 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 09:51:30 -0500 Subject: [PATCH 03/20] Add tab to cable connect view --- netbox/templates/dcim/cable_connect.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index 03dcaa2e4..c5fba3ad0 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -5,6 +5,14 @@ {% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %} +{% block tabs %} + +{% endblock %} + {% block content-wrapper %}
{% with termination_a=form.instance.termination_a %} From b6e157f393f253f30db8075f8e1bf16f763fb9b7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 10:08:31 -0500 Subject: [PATCH 04/20] Add features summary to README --- README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39981a2b0..888b881ab 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,46 @@ ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) NetBox is an infrastructure resource modeling (IRM) tool designed to empower -network automation. Initially conceived by the network engineering team at +network automation, used by thousands of organizations around the world. +Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It is intended to function as a domain-specific source of truth for network operations. +Myriad infrastructure components can be modeled in NetBox, including: + +* Hierarchical regions, site groups, sites, and locations +* Racks, devices, and device components +* Cables and wireless connections +* Power distribution +* Data circuits and providers +* Virtual machines and clusters +* IP prefixes, ranges, and addresses +* VRFs and route targets +* FHRP groups (VRRP, HSRP, etc.) +* AS numbers +* VLANs and scoped VLAN groups +* Organizational tenants and contacts + +In addition to its extensive built-in models and functionality, NetBox can be +customized and extended through the use of: + +* Custom fields +* Custom links +* Configuration contexts +* Custom model validation rules +* Reports +* Custom scripts +* Export templates +* Conditional webhooks +* Plugins +* Single sign-on (SSO) authentication +* NAPALM integration +* Detailed change logging + +NetBox also features a complete REST API as well as a GraphQL API for easily +integrating with other tools and systems. + NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). From f7d91b7139329dee0b7415f6041c0e80fd2d21df Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 10:12:28 -0500 Subject: [PATCH 05/20] Extend "Adding models" documentation --- docs/development/adding-models.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index 156a8ba97..d55afb2f2 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -37,23 +37,32 @@ Most models will need view classes created in `views.py` to serve the following Add the relevant URL path for each view created in the previous step to `urls.py`. -## 6. Create the FilterSet +## 6. Add relevant forms + +Depending on the type of model being added, you may need to define several types of form classes. These include: + +* A base model form (for creating/editing individual objects) +* A bulk edit form +* A bulk import form (for CSV-based import) +* A filterset form (for filtering the object list view) + +## 7. Create the FilterSet Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class. -## 7. Create the table class +## 8. Create the table class Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns. -## 8. Create the object template +## 9. Create the object template Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`. -## 9. Add the model to the navigation menu +## 10. Add the model to the navigation menu Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`. -## 10. REST API components +## 11. REST API components Create the following for each model: @@ -62,13 +71,13 @@ Create the following for each model: * API view in `api/views.py` * Endpoint route in `api/urls.py` -## 11. GraphQL API components +## 12. GraphQL API components Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. -## 12. Add tests +## 13. Add tests Add tests for the following: @@ -76,7 +85,7 @@ Add tests for the following: * API views * Filter sets -## 13. Documentation +## 14. Documentation Create a new documentation page for the model in `docs/models//.md`. Include this file under the "features" documentation where appropriate. From 67aeb380e700f54690b72bbe91c2182e2037dc0c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 11:46:09 -0500 Subject: [PATCH 06/20] Fix DNS name label in IP address bulk edit form --- netbox/ipam/forms/bulk_edit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index edb14a25c..162328e97 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -302,7 +302,8 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ) dns_name = forms.CharField( max_length=255, - required=False + required=False, + label='DNS name' ) description = forms.CharField( max_length=100, From 68f92dfd5d0e48dd83f710cffcad4eea07f3f401 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 11:47:21 -0500 Subject: [PATCH 07/20] Fix redirection URL for prefix IP ranges view --- netbox/templates/ipam/prefix/ip_ranges.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index f8b70f39a..af80578a0 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -3,7 +3,7 @@ {% block extra_controls %} {% if perms.ipam.add_iprange and first_available_ip %} - + Add IP Range {% endif %} From 2fa8e27f05e68582ce5283c12fa2b6e980d89461 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 12:00:37 -0500 Subject: [PATCH 08/20] Fixes #8192: Add "add prefix" button to aggregate child prefixes view --- docs/release-notes/version-3.1.md | 4 ++ netbox/ipam/models/ip.py | 45 ++++++++++--------- netbox/ipam/views.py | 1 + netbox/templates/ipam/aggregate/prefixes.html | 5 +++ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index f10459b96..9112aa100 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,6 +2,10 @@ ## v3.1.4 (FUTURE) +### Enhancements + +* [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view + ### Bug Fixes * [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index aeb71e70f..9c00a9068 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -32,6 +32,28 @@ __all__ = ( ) +class GetAvailablePrefixesMixin: + + def get_available_prefixes(self): + """ + Return all available Prefixes within this aggregate as an IPSet. + """ + prefix = netaddr.IPSet(self.prefix) + child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()]) + available_prefixes = prefix - child_prefixes + + return available_prefixes + + def get_first_available_prefix(self): + """ + Return the first available child prefix within the prefix (or None). + """ + available_prefixes = self.get_available_prefixes() + if not available_prefixes: + return None + return available_prefixes.iter_cidrs()[0] + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ @@ -110,7 +132,7 @@ class ASN(PrimaryModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Aggregate(PrimaryModel): +class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -245,7 +267,7 @@ class Role(OrganizationalModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Prefix(PrimaryModel): +class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -458,16 +480,6 @@ class Prefix(PrimaryModel): else: return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf) - def get_available_prefixes(self): - """ - Return all available Prefixes within this prefix as an IPSet. - """ - prefix = netaddr.IPSet(self.prefix) - child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()]) - available_prefixes = prefix - child_prefixes - - return available_prefixes - def get_available_ips(self): """ Return all available IPs within this prefix as an IPSet. @@ -494,15 +506,6 @@ class Prefix(PrimaryModel): return available_ips - def get_first_available_prefix(self): - """ - Return the first available child prefix within the prefix (or None). - """ - available_prefixes = self.get_available_prefixes() - if not available_prefixes: - return None - return available_prefixes.iter_cidrs()[0] - def get_first_available_ip(self): """ Return the first available IP within the prefix (or None). diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 317caeaf2..501fe1153 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -299,6 +299,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView): return { 'bulk_querystring': f'within={instance.prefix}', 'active_tab': 'prefixes', + 'first_available_prefix': instance.get_first_available_prefix(), 'show_available': bool(request.GET.get('show_available', 'true') == 'true'), 'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'), } diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index 22a74bd45..3f805fc2e 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -3,6 +3,11 @@ {% block extra_controls %} {% include 'ipam/inc/toggle_available.html' %} + {% if perms.ipam.add_prefix and first_available_prefix %} + + Add Prefix + + {% endif %} {{ block.super }} {% endblock %} From 5829985ca8297ef76f0b560381064407da6cd241 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 12:02:20 -0500 Subject: [PATCH 09/20] Remove power utilization as default column from racks table --- netbox/dcim/tables/racks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 30c560d88..14bbe3589 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -92,7 +92,7 @@ class RackTable(BaseTable): ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', - 'get_utilization', 'get_power_utilization', + 'get_utilization', ) From ab98aa489ca62ebccb3124cafb72fb0aeb1b51d8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 12:43:37 -0500 Subject: [PATCH 10/20] Related objects should be prefetched for Prefix/IPRange child object views --- netbox/ipam/views.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 501fe1153..55ac284d1 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -456,7 +456,9 @@ class PrefixPrefixesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/prefixes.html' def get_children(self, request, parent): - return parent.get_child_prefixes().restrict(request.user, 'view') + return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( + 'site', 'vrf', 'vlan', 'role', 'tenant', + ) def prep_table_data(self, request, queryset, parent): # Determine whether to show assigned prefixes, available prefixes, or both @@ -483,7 +485,9 @@ class PrefixIPRangesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/ip_ranges.html' def get_children(self, request, parent): - return parent.get_child_ranges().restrict(request.user, 'view') + return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( + 'vrf', 'role', 'tenant', + ) def get_extra_context(self, request, instance): return { @@ -501,7 +505,9 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view') + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( + 'vrf', 'role', 'tenant', + ) def prep_table_data(self, request, queryset, parent): show_available = bool(request.GET.get('show_available', 'true') == 'true') @@ -570,7 +576,9 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/iprange/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view') + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( + 'vrf', 'role', 'tenant', + ) def get_extra_context(self, request, instance): return { From 51851f6c99f34220c139aa6497b0853d5fd51471 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 13:02:54 -0500 Subject: [PATCH 11/20] Refactor users.admin --- netbox/users/admin.py | 294 --------------------------------- netbox/users/admin/__init__.py | 125 ++++++++++++++ netbox/users/admin/filters.py | 42 +++++ netbox/users/admin/forms.py | 102 ++++++++++++ netbox/users/admin/inlines.py | 49 ++++++ 5 files changed, 318 insertions(+), 294 deletions(-) delete mode 100644 netbox/users/admin.py create mode 100644 netbox/users/admin/__init__.py create mode 100644 netbox/users/admin/filters.py create mode 100644 netbox/users/admin/forms.py create mode 100644 netbox/users/admin/inlines.py diff --git a/netbox/users/admin.py b/netbox/users/admin.py deleted file mode 100644 index 740d1558d..000000000 --- a/netbox/users/admin.py +++ /dev/null @@ -1,294 +0,0 @@ -from django import forms -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import Group, User -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, ValidationError - -from utilities.forms.fields import ContentTypeMultipleChoiceField -from .constants import * -from .models import ObjectPermission, Token, UserConfig - - -# -# Inline models -# - -class ObjectPermissionInline(admin.TabularInline): - exclude = None - extra = 3 - readonly_fields = ['object_types', 'actions', 'constraints'] - verbose_name = 'Permission' - verbose_name_plural = 'Permissions' - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('objectpermission__object_types') - - @staticmethod - def object_types(instance): - # Don't call .values_list() here because we want to reference the pre-fetched object_types - return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()]) - - @staticmethod - def actions(instance): - return ', '.join(instance.objectpermission.actions) - - @staticmethod - def constraints(instance): - return instance.objectpermission.constraints - - -class GroupObjectPermissionInline(ObjectPermissionInline): - model = Group.object_permissions.through - - -class UserObjectPermissionInline(ObjectPermissionInline): - model = User.object_permissions.through - - -class UserConfigInline(admin.TabularInline): - model = UserConfig - readonly_fields = ('data',) - can_delete = False - verbose_name = 'Preferences' - - -# -# Users & groups -# - -# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below -admin.site.unregister(Group) -admin.site.unregister(User) - - -@admin.register(Group) -class GroupAdmin(admin.ModelAdmin): - fields = ('name',) - list_display = ('name', 'user_count') - ordering = ('name',) - search_fields = ('name',) - inlines = [GroupObjectPermissionInline] - - @staticmethod - def user_count(obj): - return obj.user_set.count() - - -@admin.register(User) -class UserAdmin(UserAdmin_): - list_display = [ - 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' - ] - fieldsets = ( - (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), - ('Groups', {'fields': ('groups',)}), - ('Status', { - 'fields': ('is_active', 'is_staff', 'is_superuser'), - }), - ('Important dates', {'fields': ('last_login', 'date_joined')}), - ) - filter_horizontal = ('groups',) - list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name') - - def get_inlines(self, request, obj): - if obj is not None: - return (UserObjectPermissionInline, UserConfigInline) - return () - - -# -# REST API tokens -# - -class TokenAdminForm(forms.ModelForm): - key = forms.CharField( - required=False, - help_text="If no key is provided, one will be generated automatically." - ) - - class Meta: - fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description' - ] - model = Token - - -@admin.register(Token) -class TokenAdmin(admin.ModelAdmin): - form = TokenAdminForm - list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' - ] - - -# -# Permissions -# - -class ObjectPermissionForm(forms.ModelForm): - object_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES - ) - can_view = forms.BooleanField(required=False) - can_add = forms.BooleanField(required=False) - can_change = forms.BooleanField(required=False) - can_delete = forms.BooleanField(required=False) - - class Meta: - model = ObjectPermission - exclude = [] - help_texts = { - 'actions': 'Actions granted in addition to those listed above', - 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' - 'to match all objects of this type. A list of multiple objects will result in a logical OR ' - 'operation.' - } - labels = { - 'actions': 'Additional actions' - } - widgets = { - 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Make the actions field optional since the admin form uses it only for non-CRUD actions - self.fields['actions'].required = False - - # Order group and user fields - self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') - self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') - - # Check the appropriate checkboxes when editing an existing ObjectPermission - if self.instance.pk: - for action in ['view', 'add', 'change', 'delete']: - if action in self.instance.actions: - self.fields[f'can_{action}'].initial = True - self.instance.actions.remove(action) - - def clean(self): - super().clean() - - object_types = self.cleaned_data.get('object_types') - constraints = self.cleaned_data.get('constraints') - - # Append any of the selected CRUD checkboxes to the actions list - if not self.cleaned_data.get('actions'): - self.cleaned_data['actions'] = list() - for action in ['view', 'add', 'change', 'delete']: - if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: - self.cleaned_data['actions'].append(action) - - # At least one action must be specified - if not self.cleaned_data['actions']: - raise ValidationError("At least one action must be selected.") - - # Validate the specified model constraints by attempting to execute a query. We don't care whether the query - # returns anything; we just want to make sure the specified constraints are valid. - if object_types and constraints: - # Normalize the constraints to a list of dicts - if type(constraints) is not list: - constraints = [constraints] - for ct in object_types: - model = ct.model_class() - try: - model.objects.filter(*[Q(**c) for c in constraints]).exists() - except FieldError as e: - raise ValidationError({ - 'constraints': f'Invalid filter for {model}: {e}' - }) - - -class ActionListFilter(admin.SimpleListFilter): - title = 'action' - parameter_name = 'action' - - def lookups(self, request, model_admin): - options = set() - for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct(): - options.update(action_list) - return [ - (action, action) for action in sorted(options) - ] - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(actions=[self.value()]) - - -class ObjectTypeListFilter(admin.SimpleListFilter): - title = 'object type' - parameter_name = 'object_type' - - def lookups(self, request, model_admin): - object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct() - content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model') - return [ - (ct.pk, ct) for ct in content_types - ] - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(object_types=self.value()) - - -@admin.register(ObjectPermission) -class ObjectPermissionAdmin(admin.ModelAdmin): - actions = ('enable', 'disable') - fieldsets = ( - (None, { - 'fields': ('name', 'description', 'enabled') - }), - ('Actions', { - 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') - }), - ('Objects', { - 'fields': ('object_types',) - }), - ('Assignment', { - 'fields': ('groups', 'users') - }), - ('Constraints', { - 'fields': ('constraints',), - 'classes': ('monospace',) - }), - ) - filter_horizontal = ('object_types', 'groups', 'users') - form = ObjectPermissionForm - list_display = [ - 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', - ] - list_filter = [ - 'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users' - ] - search_fields = ['actions', 'constraints', 'description', 'name'] - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') - - def list_models(self, obj): - return ', '.join([f"{ct}" for ct in obj.object_types.all()]) - list_models.short_description = 'Models' - - def list_users(self, obj): - return ', '.join([u.username for u in obj.users.all()]) - list_users.short_description = 'Users' - - def list_groups(self, obj): - return ', '.join([g.name for g in obj.groups.all()]) - list_groups.short_description = 'Groups' - - # - # Admin actions - # - - def enable(self, request, queryset): - updated = queryset.update(enabled=True) - self.message_user(request, f"Enabled {updated} permissions") - - def disable(self, request, queryset): - updated = queryset.update(enabled=False) - self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py new file mode 100644 index 000000000..f2e2e0ed5 --- /dev/null +++ b/netbox/users/admin/__init__.py @@ -0,0 +1,125 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as UserAdmin_ +from django.contrib.auth.models import Group, User + +from users.models import ObjectPermission, Token +from . import filters, forms, inlines + + +# +# Users & groups +# + +# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below +admin.site.unregister(Group) +admin.site.unregister(User) + + +@admin.register(Group) +class GroupAdmin(admin.ModelAdmin): + fields = ('name',) + list_display = ('name', 'user_count') + ordering = ('name',) + search_fields = ('name',) + inlines = [inlines.GroupObjectPermissionInline] + + @staticmethod + def user_count(obj): + return obj.user_set.count() + + +@admin.register(User) +class UserAdmin(UserAdmin_): + list_display = [ + 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' + ] + fieldsets = ( + (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), + ('Groups', {'fields': ('groups',)}), + ('Status', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + }), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + filter_horizontal = ('groups',) + list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name') + + def get_inlines(self, request, obj): + if obj is not None: + return (inlines.UserObjectPermissionInline, inlines.UserConfigInline) + return () + + +# +# REST API tokens +# + +@admin.register(Token) +class TokenAdmin(admin.ModelAdmin): + form = forms.TokenAdminForm + list_display = [ + 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + ] + + +# +# Permissions +# + +@admin.register(ObjectPermission) +class ObjectPermissionAdmin(admin.ModelAdmin): + actions = ('enable', 'disable') + fieldsets = ( + (None, { + 'fields': ('name', 'description', 'enabled') + }), + ('Actions', { + 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') + }), + ('Objects', { + 'fields': ('object_types',) + }), + ('Assignment', { + 'fields': ('groups', 'users') + }), + ('Constraints', { + 'fields': ('constraints',), + 'classes': ('monospace',) + }), + ) + filter_horizontal = ('object_types', 'groups', 'users') + form = forms.ObjectPermissionForm + list_display = [ + 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', + ] + list_filter = [ + 'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users' + ] + search_fields = ['actions', 'constraints', 'description', 'name'] + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') + + def list_models(self, obj): + return ', '.join([f"{ct}" for ct in obj.object_types.all()]) + list_models.short_description = 'Models' + + def list_users(self, obj): + return ', '.join([u.username for u in obj.users.all()]) + list_users.short_description = 'Users' + + def list_groups(self, obj): + return ', '.join([g.name for g in obj.groups.all()]) + list_groups.short_description = 'Groups' + + # + # Admin actions + # + + def enable(self, request, queryset): + updated = queryset.update(enabled=True) + self.message_user(request, f"Enabled {updated} permissions") + + def disable(self, request, queryset): + updated = queryset.update(enabled=False) + self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/admin/filters.py b/netbox/users/admin/filters.py new file mode 100644 index 000000000..b71761fa2 --- /dev/null +++ b/netbox/users/admin/filters.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from django.contrib.contenttypes.models import ContentType + +from users.models import ObjectPermission + +__all__ = ( + 'ActionListFilter', + 'ObjectTypeListFilter', +) + + +class ActionListFilter(admin.SimpleListFilter): + title = 'action' + parameter_name = 'action' + + def lookups(self, request, model_admin): + options = set() + for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct(): + options.update(action_list) + return [ + (action, action) for action in sorted(options) + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(actions=[self.value()]) + + +class ObjectTypeListFilter(admin.SimpleListFilter): + title = 'object type' + parameter_name = 'object_type' + + def lookups(self, request, model_admin): + object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct() + content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model') + return [ + (ct.pk, ct) for ct in content_types + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(object_types=self.value()) diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py new file mode 100644 index 000000000..6d94859cd --- /dev/null +++ b/netbox/users/admin/forms.py @@ -0,0 +1,102 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldError, ValidationError +from django.db.models import Q + +from users.constants import OBJECTPERMISSION_OBJECT_TYPES +from users.models import ObjectPermission, Token +from utilities.forms.fields import ContentTypeMultipleChoiceField + +__all__ = ( + 'ObjectPermissionForm', + 'TokenAdminForm', +) + + +class TokenAdminForm(forms.ModelForm): + key = forms.CharField( + required=False, + help_text="If no key is provided, one will be generated automatically." + ) + + class Meta: + fields = [ + 'user', 'key', 'write_enabled', 'expires', 'description' + ] + model = Token + + +class ObjectPermissionForm(forms.ModelForm): + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES + ) + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) + + class Meta: + model = ObjectPermission + exclude = [] + help_texts = { + 'actions': 'Actions granted in addition to those listed above', + 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.' + } + labels = { + 'actions': 'Additional actions' + } + widgets = { + 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the actions field optional since the admin form uses it only for non-CRUD actions + self.fields['actions'].required = False + + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance.pk: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + + def clean(self): + super().clean() + + object_types = self.cleaned_data.get('object_types') + constraints = self.cleaned_data.get('constraints') + + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise ValidationError("At least one action must be selected.") + + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if object_types and constraints: + # Normalize the constraints to a list of dicts + if type(constraints) is not list: + constraints = [constraints] + for ct in object_types: + model = ct.model_class() + try: + model.objects.filter(*[Q(**c) for c in constraints]).exists() + except FieldError as e: + raise ValidationError({ + 'constraints': f'Invalid filter for {model}: {e}' + }) diff --git a/netbox/users/admin/inlines.py b/netbox/users/admin/inlines.py new file mode 100644 index 000000000..cd192ecf8 --- /dev/null +++ b/netbox/users/admin/inlines.py @@ -0,0 +1,49 @@ +from django.contrib import admin +from django.contrib.auth.models import Group, User + +from users.models import UserConfig + +__all__ = ( + 'GroupObjectPermissionInline', + 'UserConfigInline', + 'UserObjectPermissionInline', +) + + +class ObjectPermissionInline(admin.TabularInline): + exclude = None + extra = 3 + readonly_fields = ['object_types', 'actions', 'constraints'] + verbose_name = 'Permission' + verbose_name_plural = 'Permissions' + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('objectpermission__object_types') + + @staticmethod + def object_types(instance): + # Don't call .values_list() here because we want to reference the pre-fetched object_types + return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()]) + + @staticmethod + def actions(instance): + return ', '.join(instance.objectpermission.actions) + + @staticmethod + def constraints(instance): + return instance.objectpermission.constraints + + +class GroupObjectPermissionInline(ObjectPermissionInline): + model = Group.object_permissions.through + + +class UserObjectPermissionInline(ObjectPermissionInline): + model = User.object_permissions.through + + +class UserConfigInline(admin.TabularInline): + model = UserConfig + readonly_fields = ('data',) + can_delete = False + verbose_name = 'Preferences' From cdd51aee750e588d5f3164c38dd21bf42dfba2c8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 13:19:18 -0500 Subject: [PATCH 12/20] Closes #8194: Enable bulk user assignment to groups under admin UI --- docs/release-notes/version-3.1.md | 1 + netbox/users/admin/__init__.py | 2 +- netbox/users/admin/forms.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 9112aa100..90c7520e2 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -5,6 +5,7 @@ ### Enhancements * [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view +* [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI ### Bug Fixes diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index f2e2e0ed5..1b163ed06 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -17,7 +17,7 @@ admin.site.unregister(User) @admin.register(Group) class GroupAdmin(admin.ModelAdmin): - fields = ('name',) + form = forms.GroupAdminForm list_display = ('name', 'user_count') ordering = ('name',) search_fields = ('name',) diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 6d94859cd..7d0212441 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -1,4 +1,6 @@ from django import forms +from django.contrib.auth.models import Group, User +from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError from django.db.models import Q @@ -8,11 +10,39 @@ from users.models import ObjectPermission, Token from utilities.forms.fields import ContentTypeMultipleChoiceField __all__ = ( + 'GroupAdminForm', 'ObjectPermissionForm', 'TokenAdminForm', ) +class GroupAdminForm(forms.ModelForm): + users = forms.ModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + widget=FilteredSelectMultiple('users', False) + ) + + class Meta: + model = Group + fields = ('name', 'users') + + def __init__(self, *args, **kwargs): + super(GroupAdminForm, self).__init__(*args, **kwargs) + + if self.instance.pk: + self.fields['users'].initial = self.instance.user_set.all() + + def save_m2m(self): + self.instance.user_set.set(self.cleaned_data['users']) + + def save(self, *args, **kwargs): + instance = super(GroupAdminForm, self).save() + self.save_m2m() + + return instance + + class TokenAdminForm(forms.ModelForm): key = forms.CharField( required=False, From caaad684a410fecac7a73c4e5ef8325d6b252d08 Mon Sep 17 00:00:00 2001 From: netbja Date: Fri, 31 Dec 2021 11:25:12 +0100 Subject: [PATCH 13/20] Small syntax error No double quotes after password. --- docs/rest-api/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 1571f15fa..11b8cd6bf 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -42,7 +42,7 @@ $ curl -X POST \ https://netbox/api/users/tokens/provision/ \ --data '{ "username": "hankhill", - "password: "I<3C3H8", + "password": "I<3C3H8", }' ``` From e18dc43aae8b06652ac04f46cf452cdfd5e199a0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 3 Jan 2022 09:17:15 -0500 Subject: [PATCH 14/20] Fixes #8196: Fix IndexError exception when viewing large IPv6 prefixes in UI --- docs/release-notes/version-3.1.md | 3 ++- netbox/templates/ipam/prefix.html | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 90c7520e2..c994d8305 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -9,8 +9,9 @@ ### Bug Fixes -* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces * [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables +* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces +* [#8196](https://github.com/netbox-community/netbox/issues/8196) - Fix IndexError exception when viewing large IPv6 prefixes in UI --- diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index c3d3bdedd..cb62e56ae 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -1,4 +1,5 @@ {% extends 'ipam/prefix/base.html' %} +{% load humanize %} {% load helpers %} {% load plugins %} @@ -124,9 +125,18 @@ {{ child_ip_count }} + {% endwith %} + {% with available_count=object.get_available_ips.size %} Available IPs - {{ object.get_available_ips|length }} + + {# Use human-friendly words for counts greater than one million #} + {% if available_count > 1000000 %} + {{ available_count|intword }} + {% else %} + {{ available_count|intcomma }} + {% endif %} + {% endwith %} From 1c7604e0fe40c58857efc7a7f32c3193269b58dd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 3 Jan 2022 09:20:26 -0500 Subject: [PATCH 15/20] Fixes #8200: Correct typo in navigation menu --- netbox/netbox/navigation_menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 488fa163d..130d2dee8 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -176,7 +176,7 @@ CONNECTIONS_MENU = Menu( label='Connections', items=( get_model_item('dcim', 'cable', 'Cables', actions=['import']), - get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), + get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']), MenuItem( link='dcim:interface_connections_list', link_text='Interface Connections', From 7b0dff88aed539195725552e57ba6ff7c17ce03f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 3 Jan 2022 09:45:30 -0500 Subject: [PATCH 16/20] Closes #8210: Establish netbox/local/ as a path for local resources --- .gitignore | 1 + docs/installation/6-ldap.md | 2 +- docs/release-notes/version-3.1.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0ce9a20a8..93954fd41 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ yarn-error.log* !/netbox/project-static/docs/.info /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/local/* /netbox/reports/* !/netbox/reports/__init__.py /netbox/scripts/* diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 86114dfb0..281554f75 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -152,7 +152,7 @@ LOGGING = { 'netbox_auth_log': { 'level': 'DEBUG', 'class': 'logging.handlers.RotatingFileHandler', - 'filename': '/opt/netbox/logs/django-ldap-debug.log', + 'filename': '/opt/netbox/local/logs/django-ldap-debug.log', 'maxBytes': 1024 * 500, 'backupCount': 5, }, diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index c994d8305..cb8831731 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ * [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view * [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI +* [#8210](https://github.com/netbox-community/netbox/issues/8210) - Establish `netbox/local/` as a path for local resources ### Bug Fixes From 05d4176d34ae224249df0abc32eedc3d6e3dfe23 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 3 Jan 2022 10:07:19 -0500 Subject: [PATCH 17/20] Fixes #8201: Custom integer fields should allow negative integers as minimum/maximum values --- docs/release-notes/version-3.1.md | 1 + .../0067_customfield_min_max_values.py | 21 +++++ netbox/extras/models/customfields.py | 4 +- netbox/extras/tests/test_customfields.py | 79 ++++++++++++------- 4 files changed, 73 insertions(+), 32 deletions(-) create mode 100644 netbox/extras/migrations/0067_customfield_min_max_values.py diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index cb8831731..d8e56b1ee 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -13,6 +13,7 @@ * [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables * [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces * [#8196](https://github.com/netbox-community/netbox/issues/8196) - Fix IndexError exception when viewing large IPv6 prefixes in UI +* [#8201](https://github.com/netbox-community/netbox/issues/8201) - Custom integer fields should allow negative integers as minimum/maximum values --- diff --git a/netbox/extras/migrations/0067_customfield_min_max_values.py b/netbox/extras/migrations/0067_customfield_min_max_values.py new file mode 100644 index 000000000..cec4f6ae0 --- /dev/null +++ b/netbox/extras/migrations/0067_customfield_min_max_values.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0066_customfield_name_validation'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='validation_maximum', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='customfield', + name='validation_minimum', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 713ef6c93..8c817ad33 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -96,13 +96,13 @@ class CustomField(ChangeLoggedModel): default=100, help_text='Fields with higher weights appear lower in a form.' ) - validation_minimum = models.PositiveIntegerField( + validation_minimum = models.IntegerField( blank=True, null=True, verbose_name='Minimum value', help_text='Minimum allowed value (for numeric fields)' ) - validation_maximum = models.PositiveIntegerField( + validation_maximum = models.IntegerField( blank=True, null=True, verbose_name='Maximum value', diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 5a9c4257f..fdabe0fcf 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -25,49 +25,68 @@ class CustomFieldTest(TestCase): def test_simple_fields(self): DATA = ( { - 'field_type': CustomFieldTypeChoices.TYPE_TEXT, - 'field_value': 'Foobar!', - 'empty_value': '', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_TEXT, + }, + 'value': 'Foobar!', }, { - 'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT, - 'field_value': 'Text with **Markdown**', - 'empty_value': '', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_LONGTEXT, + }, + 'value': 'Text with **Markdown**', }, { - 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, - 'field_value': 0, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_INTEGER, + }, + 'value': 0, }, { - 'field_type': CustomFieldTypeChoices.TYPE_INTEGER, - 'field_value': 42, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_INTEGER, + 'validation_minimum': 1, + 'validation_maximum': 100, + }, + 'value': 42, }, { - 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, - 'field_value': True, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_INTEGER, + 'validation_minimum': -100, + 'validation_maximum': -1, + }, + 'value': -42, }, { - 'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, - 'field_value': False, - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, + }, + 'value': True, }, { - 'field_type': CustomFieldTypeChoices.TYPE_DATE, - 'field_value': '2016-06-23', - 'empty_value': None, + 'field': { + 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, + }, + 'value': False, }, { - 'field_type': CustomFieldTypeChoices.TYPE_URL, - 'field_value': 'http://example.com/', - 'empty_value': '', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_DATE, + }, + 'value': '2016-06-23', }, { - 'field_type': CustomFieldTypeChoices.TYPE_JSON, - 'field_value': '{"foo": 1, "bar": 2}', - 'empty_value': 'null', + 'field': { + 'type': CustomFieldTypeChoices.TYPE_URL, + }, + 'value': 'http://example.com/', + }, + { + 'field': { + 'type': CustomFieldTypeChoices.TYPE_JSON, + }, + 'value': '{"foo": 1, "bar": 2}', }, ) @@ -76,7 +95,7 @@ class CustomFieldTest(TestCase): for data in DATA: # Create a custom field - cf = CustomField(type=data['field_type'], name='my_field', required=False) + cf = CustomField(name='my_field', required=False, **data['field']) cf.save() cf.content_types.set([obj_type]) @@ -85,12 +104,12 @@ class CustomFieldTest(TestCase): self.assertIsNone(site.custom_field_data[cf.name]) # Assign a value to the first Site - site.custom_field_data[cf.name] = data['field_value'] + site.custom_field_data[cf.name] = data['value'] site.save() # Retrieve the stored value site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], data['field_value']) + self.assertEqual(site.custom_field_data[cf.name], data['value']) # Delete the stored value site.custom_field_data.pop(cf.name) From ecb9fc65b78017988358a0418cc98be9b5df5f68 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 3 Jan 2022 10:41:43 -0500 Subject: [PATCH 18/20] Closes #8197: Allow filtering sites by group when connecting a cable --- docs/release-notes/version-3.1.md | 1 + netbox/dcim/forms/connections.py | 29 ++++++++++++------------ netbox/templates/dcim/cable_connect.html | 9 ++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index d8e56b1ee..ba31afc7a 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ * [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view * [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI +* [#8197](https://github.com/netbox-community/netbox/issues/8197) - Allow filtering sites by group when connecting a cable * [#8210](https://github.com/netbox-community/netbox/issues/8210) - Establish `netbox/local/` as a path for local resources ### Bug Fixes diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 771ff38bc..6a7a09023 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -27,7 +27,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): label='Region', required=False ) - termination_b_site_group = DynamicModelChoiceField( + termination_b_sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), label='Site group', required=False @@ -38,7 +38,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): required=False, query_params={ 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', + 'group_id': '$termination_b_sitegroup', } ) termination_b_location = DynamicModelChoiceField( @@ -78,9 +78,9 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', - 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', + 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack', + 'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', + 'length', 'length_unit', 'tags', ] widgets = { 'status': StaticSelect, @@ -182,7 +182,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): label='Region', required=False ) - termination_b_site_group = DynamicModelChoiceField( + termination_b_sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), label='Site group', required=False @@ -193,7 +193,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): required=False, query_params={ 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', + 'group_id': '$termination_b_sitegroup', } ) termination_b_circuit = DynamicModelChoiceField( @@ -219,9 +219,9 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): class Meta(ConnectCableToDeviceForm.Meta): fields = [ - 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', - 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', + 'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', + 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', + 'length', 'length_unit', 'tags', ] def clean_termination_b_id(self): @@ -235,7 +235,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): label='Region', required=False ) - termination_b_site_group = DynamicModelChoiceField( + termination_b_sitegroup = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), label='Site group', required=False @@ -246,7 +246,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): required=False, query_params={ 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', + 'group_id': '$termination_b_sitegroup', } ) termination_b_location = DynamicModelChoiceField( @@ -281,8 +281,9 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): class Meta(ConnectCableToDeviceForm.Meta): fields = [ - 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', - 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', + 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location', + 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', + 'color', 'length', 'length_unit', 'tags', ] def clean_termination_b_id(self): diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index c5fba3ad0..1d50040c7 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -35,6 +35,12 @@
+
+ +
+ +
+
@@ -123,6 +129,9 @@ {% if 'termination_b_region' in form.fields %} {% render_field form.termination_b_region %} {% endif %} + {% if 'termination_b_sitegroup' in form.fields %} + {% render_field form.termination_b_sitegroup %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} From 9de53fe07011128325c43b2db92fd0dca79239e2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 3 Jan 2022 11:00:23 -0500 Subject: [PATCH 19/20] Release v3.1.4 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.1.md | 2 +- netbox/netbox/settings.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 23d5b8182..17533e2c9 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.3 + placeholder: v3.1.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 00b464515..1c31f0c29 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.3 + placeholder: v3.1.4 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ba31afc7a..670cc4cce 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,6 +1,6 @@ # NetBox v3.1 -## v3.1.4 (FUTURE) +## v3.1.4 (2022-01-03) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9435b49dd..c22443275 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.4-dev' +VERSION = '3.1.4' # Hostname HOSTNAME = platform.node() From 79bebf7c9b9043a31ab32d0e2d2e39f5c7cb33fa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 3 Jan 2022 11:18:46 -0500 Subject: [PATCH 20/20] PRVB --- docs/release-notes/version-3.1.md | 4 ++++ netbox/netbox/settings.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 670cc4cce..29213a4c5 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,5 +1,9 @@ # NetBox v3.1 +## v3.1.5 (FUTURE) + +--- + ## v3.1.4 (2022-01-03) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c22443275..ef0470038 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.4' +VERSION = '3.1.5-dev' # Hostname HOSTNAME = platform.node()