From 27d72746ca4aec377001cfc69fa416bf4bd652dc Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 8 Sep 2022 13:20:27 -0700 Subject: [PATCH 01/19] #10172 upgrade Django to 4.1.1 --- base_requirements.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 363f97b31..22106587d 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -4,7 +4,7 @@ bleach # The Python web framework on which NetBox is built # https://github.com/django/django -Django<4.1 +Django<4.2 # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers diff --git a/requirements.txt b/requirements.txt index ddbf07b9b..37659c523 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ bleach==5.0.1 -Django==4.0.7 +Django==4.1.1 django-cors-headers==3.13.0 django-debug-toolbar==3.6.0 django-filter==22.1 From ce6bf9e5c1bc08edc80f6ea1e55cf1318ae6e14b Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 09:59:37 -0700 Subject: [PATCH 02/19] #10172 fixes for Django 4.1 --- netbox/dcim/models/device_components.py | 13 +++++++------ netbox/dcim/models/devices.py | 2 +- netbox/virtualization/models.py | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 838336e21..e22913a8b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -953,12 +953,13 @@ class RearPort(ModularComponentModel, CabledObjectModel): super().clean() # Check that positions count is greater than or equal to the number of associated FrontPorts - frontport_count = self.frontports.count() - if self.positions < frontport_count: - raise ValidationError({ - "positions": f"The number of positions cannot be less than the number of mapped front ports " - f"({frontport_count})" - }) + if self.pk: + frontport_count = self.frontports.count() + if self.positions < frontport_count: + raise ValidationError({ + "positions": f"The number of positions cannot be less than the number of mapped front ports " + f"({frontport_count})" + }) # diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ccf4613bf..7858960a1 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -268,7 +268,7 @@ class DeviceType(NetBoxModel): if ( self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT - ) and self.devicebaytemplates.count(): + ) and self.pk and self.devicebaytemplates.count(): raise ValidationError({ 'subdevice_role': "Must delete all device bay templates associated with this device before " "declassifying it as a parent device." diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index abad57f88..4acbe6daf 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -367,7 +367,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): }) # Validate primary IP addresses - interfaces = self.interfaces.all() + interfaces = self.interfaces.all() if self.pk else None for family in (4, 6): field = f'primary_ip{family}' ip = getattr(self, field) From 6a9274a95f87ad8c3c5dcdb1da47d6781d70a4da Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Sep 2022 14:36:37 -0400 Subject: [PATCH 03/19] Closes #10314: Move clone() method from NetBoxModel to CloningMixin --- docs/plugins/development/models.md | 20 ++------------------ docs/release-notes/version-3.4.md | 5 +++++ mkdocs.yml | 1 + netbox/netbox/models/features.py | 13 +++++++++++-- 4 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 docs/release-notes/version-3.4.md diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index c58621b81..16f5dd0df 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -49,24 +49,6 @@ class MyModel(NetBoxModel): ... ``` -### The `clone()` Method - -!!! info - This method was introduced in NetBox v3.3. - -The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. - -Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content: - -```python -class MyModel(NetBoxModel): - - def clone(self): - attrs = super().clone() - attrs['extra-value'] = 123 - return attrs -``` - ### 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. (Your model will also need to inherit from Django's built-in `Model` class.) @@ -116,6 +98,8 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.ChangeLoggingMixin +::: netbox.models.features.CloningMixin + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md new file mode 100644 index 000000000..88bc7810e --- /dev/null +++ b/docs/release-notes/version-3.4.md @@ -0,0 +1,5 @@ +# NetBox v3.4 + +### Plugins API + +* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin diff --git a/mkdocs.yml b/mkdocs.yml index 530c6d52e..8f6e2930a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -252,6 +252,7 @@ nav: - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.4: 'release-notes/version-3.4.md' - Version 3.3: 'release-notes/version-3.3.md' - Version 3.2: 'release-notes/version-3.2.md' - Version 3.1: 'release-notes/version-3.1.md' diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 7f30248b4..9fa1c5cef 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -92,8 +92,17 @@ class CloningMixin(models.Model): def clone(self): """ - Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- - populating an object creation form in the UI. + Returns a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- + populating an object creation form in the UI. By default, this method will replicate any fields listed in the + model's `clone_fields` list (if defined), but it can be overridden to apply custom logic. + + ```python + class MyModel(NetBoxModel): + def clone(self): + attrs = super().clone() + attrs['extra-value'] = 123 + return attrs + ``` """ attrs = {} From 4208dbd514feb2a68bf89b969246cb03d2fdf3b5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 14 Sep 2022 17:10:14 -0400 Subject: [PATCH 04/19] Closes #10358: Raise minimum required PostgreSQL version from 10 to 11 --- docs/configuration/required-parameters.md | 2 +- docs/installation/1-postgresql.md | 6 +++--- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 9 ++++----- docs/introduction.md | 2 +- docs/release-notes/version-3.4.md | 7 +++++++ 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index a62d14fef..15f743754 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 10 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index a6aa27b1b..583a4f3e9 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 10 or later required" - NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 11 or later required" + NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -35,7 +35,7 @@ sudo systemctl start postgresql sudo systemctl enable postgresql ``` -Before continuing, verify that you have installed PostgreSQL 10 or later: +Before continuing, verify that you have installed PostgreSQL 11 or later: ```no-highlight psql -V diff --git a/docs/installation/index.md b/docs/installation/index.md index 8b588fccd..49163550d 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 10 | +| PostgreSQL | 11 | | Redis | 4.0 | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 802c13e49..cc49cd30e 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -20,7 +20,7 @@ NetBox v3.0 and later require the following: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 10 | +| PostgreSQL | 11 | | Redis | 4.0 | ## 3. Install the Latest Release @@ -28,16 +28,15 @@ NetBox v3.0 and later require the following: As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. !!! warning - Use the same method as you used to install Netbox originally + Use the same method as you used to install NetBox originally -If you are not sure how Netbox was installed originally, check with this -command: +If you are not sure how NetBox was installed originally, check with this command: ``` ls -ld /opt/netbox /opt/netbox/.git ``` -If Netbox was installed from a release package, then `/opt/netbox` will be a +If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories. diff --git a/docs/introduction.md b/docs/introduction.md index cffcb37dd..fe82e68aa 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -74,6 +74,6 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 10+ | +| Database | PostgreSQL 11+ | | Task queuing | Redis/django-rq | | Live device access | NAPALM (optional) | diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 88bc7810e..39c44f38e 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,5 +1,12 @@ # NetBox v3.4 +!!! warning "PostgreSQL 11 Required" + NetBox v3.4 requires PostgreSQL 11 or later. + ### Plugins API * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin + +### Other Changes + +* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 From 695ad47fe9e4f5fa3193fe4f08afd985d6eac4d6 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 19 Sep 2022 10:46:16 -0700 Subject: [PATCH 05/19] 10408 add error message if already exists --- netbox/netbox/views/generic/object_views.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index a56a832b6..85604cd8f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -3,7 +3,7 @@ from copy import deepcopy from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction +from django.db import transaction, IntegrityError from django.db.models import ProtectedError from django.forms.widgets import HiddenInput from django.shortcuts import redirect, render @@ -421,7 +421,11 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): return redirect(return_url) - except (AbortRequest, PermissionsViolation) as e: + except IntegrityError: + form.add_error(None, f"{obj} already exists") + clear_webhooks.send(sender=self) + + except (IntegrityError, AbortRequest, PermissionsViolation) as e: logger.debug(e.message) form.add_error(None, e.message) clear_webhooks.send(sender=self) From 75c91232b49386d3014e450e6d6ef619d9b506b9 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 20 Sep 2022 09:49:46 -0500 Subject: [PATCH 06/19] Update changelog for #9497 --- docs/release-notes/version-3.3.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index d779e1a93..71f5605f9 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -8,6 +8,7 @@ ### Bug Fixes +* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view * [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI * [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface From 360172cad01c0b54ef02328ad0d9a198068ba13d Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 21 Sep 2022 15:19:40 -0400 Subject: [PATCH 07/19] Add [tool.pyright] to pyproject.toml --- pyproject.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6d579b737..177b44d86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,3 +11,12 @@ profile = "black" [tool.pylint] max-line-length = 120 + +[tool.pyright] +include = ["netbox"] +exclude = [ + "**/node_modules", + "**/__pycache__", +] +reportMissingImports = true +reportMissingTypeStubs = false From 7735634649933e1c64d410a4fbc69f6bc8c975e6 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 22 Sep 2022 10:34:37 -0700 Subject: [PATCH 08/19] 10435 check if vm.cluster in qs --- netbox/ipam/querysets.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 7edac2eff..b64ae04b8 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -81,30 +81,31 @@ class VLANQuerySet(RestrictedQuerySet): # Find all relevant VLANGroups q = Q() - if vm.cluster.site: - if vm.cluster.site.region: + if vm.cluster: + if vm.cluster.site: + if vm.cluster.site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True) + ) + if vm.cluster.site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True) + ) q |= Q( - scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), - scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True) + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=vm.cluster.site_id ) - if vm.cluster.site.group: + if vm.cluster.group: q |= Q( - scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), - scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True) + scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'), + scope_id=vm.cluster.group_id ) q |= Q( - scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), - scope_id=vm.cluster.site_id + scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'), + scope_id=vm.cluster_id ) - if vm.cluster.group: - q |= Q( - scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'), - scope_id=vm.cluster.group_id - ) - q |= Q( - scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'), - scope_id=vm.cluster_id - ) vlan_groups = VLANGroup.objects.filter(q) # Return all applicable VLANs @@ -113,7 +114,7 @@ class VLANQuerySet(RestrictedQuerySet): Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs Q(group__isnull=True, site__isnull=True) # Global VLANs ) - if vm.cluster.site: + if vm.cluster and vm.cluster.site: q |= Q(site=vm.cluster.site) return self.filter(q) From c97d2d4fe9c9fdc4f348fc03facdae79c7f2894b Mon Sep 17 00:00:00 2001 From: "Artem I. Kotik" Date: Sat, 24 Sep 2022 15:49:23 +0400 Subject: [PATCH 09/19] Add widget for Airflow field in DeviceTypeForm --- netbox/dcim/forms/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 5728e7f2d..b33023ece 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -373,6 +373,7 @@ class DeviceTypeForm(NetBoxModelForm): 'front_image', 'rear_image', 'comments', 'tags', ] widgets = { + 'airflow': StaticSelect(), 'subdevice_role': StaticSelect(), 'front_image': ClearableFileInput(attrs={ 'accept': DEVICETYPE_IMAGE_FORMATS From 39129ecedfb5755ac1c1c827abf60f5904670d80 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 26 Sep 2022 06:17:02 -0700 Subject: [PATCH 10/19] 10407 fix documentation link to requests (#10409) * 10407 fix documentation link to requests * Append page heading to URL Co-authored-by: jeremystretch --- docs/configuration/system.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 21607e566..93f8fa902 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging Default: None -A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example: +A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example: ```python HTTP_PROXIES = { From 3ad337dd15b569a644e7f0a1a22fed4970ccf295 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 26 Sep 2022 10:08:54 -0400 Subject: [PATCH 11/19] Filter VLANs and VLANGroups by site or cluster site for VM --- netbox/ipam/querysets.py | 41 +++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index b64ae04b8..9f4463f61 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -81,31 +81,34 @@ class VLANQuerySet(RestrictedQuerySet): # Find all relevant VLANGroups q = Q() + site = vm.site or vm.cluster.site if vm.cluster: - if vm.cluster.site: - if vm.cluster.site.region: - q |= Q( - scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), - scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True) - ) - if vm.cluster.site.group: - q |= Q( - scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), - scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True) - ) - q |= Q( - scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), - scope_id=vm.cluster.site_id - ) + # Add VLANGroups scoped to the assigned cluster (or its group) + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'), + scope_id=vm.cluster_id + ) if vm.cluster.group: q |= Q( scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'), scope_id=vm.cluster.group_id ) + if site: + # Add VLANGroups scoped to the assigned site (or its group or region) q |= Q( - scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'), - scope_id=vm.cluster_id + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=site.pk ) + if site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=site.region.get_ancestors(include_self=True) + ) + if site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=site.group.get_ancestors(include_self=True) + ) vlan_groups = VLANGroup.objects.filter(q) # Return all applicable VLANs @@ -114,7 +117,7 @@ class VLANQuerySet(RestrictedQuerySet): Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs Q(group__isnull=True, site__isnull=True) # Global VLANs ) - if vm.cluster and vm.cluster.site: - q |= Q(site=vm.cluster.site) + if site: + q |= Q(site=site) return self.filter(q) From fd89ef04b6eac975bf7acf2cbada4810442c0bf3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 26 Sep 2022 10:24:40 -0400 Subject: [PATCH 12/19] Revert "10408 add error message if already exists" --- netbox/netbox/views/generic/object_views.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 85604cd8f..a56a832b6 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -3,7 +3,7 @@ from copy import deepcopy from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction, IntegrityError +from django.db import transaction from django.db.models import ProtectedError from django.forms.widgets import HiddenInput from django.shortcuts import redirect, render @@ -421,11 +421,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): return redirect(return_url) - except IntegrityError: - form.add_error(None, f"{obj} already exists") - clear_webhooks.send(sender=self) - - except (IntegrityError, AbortRequest, PermissionsViolation) as e: + except (AbortRequest, PermissionsViolation) as e: logger.debug(e.message) form.add_error(None, e.message) clear_webhooks.send(sender=self) From 96784640e371d12c0dcd97d15ceae4a45663f8c0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 26 Sep 2022 10:27:35 -0400 Subject: [PATCH 13/19] Changelog for #10435, #10439 --- docs/release-notes/version-3.3.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 71f5605f9..2955e17d5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,13 +2,17 @@ ## v3.3.5 (FUTURE) +### Bug Fixes + +* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view +* [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned +* [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field + --- ## v3.3.4 (2022-09-16) ### Bug Fixes - -* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view * [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI * [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface From cf8ee16ae1eeb981debcbabea177172e6aea7bd7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 07:59:38 -0700 Subject: [PATCH 14/19] 10348 add decimal custom field --- netbox/extras/api/serializers.py | 2 ++ netbox/extras/choices.py | 2 ++ netbox/extras/models/customfields.py | 9 +++++++++ 3 files changed, 13 insertions(+) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 764c7750a..fd774f8ff 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -99,6 +99,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): types = CustomFieldTypeChoices if obj.type == types.TYPE_INTEGER: return 'integer' + if obj.type == types.TYPE_DECIMAL: + return 'decimal' if obj.type == types.TYPE_BOOLEAN: return 'boolean' if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 123fd2cd4..5afe9f33f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -10,6 +10,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_TEXT = 'text' TYPE_LONGTEXT = 'longtext' TYPE_INTEGER = 'integer' + TYPE_DECIMAL = 'decimal' TYPE_BOOLEAN = 'boolean' TYPE_DATE = 'date' TYPE_URL = 'url' @@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_TEXT, 'Text'), (TYPE_LONGTEXT, 'Text (long)'), (TYPE_INTEGER, 'Integer'), + (TYPE_DECIMAL, 'Decimal'), (TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_DATE, 'Date'), (TYPE_URL, 'URL'), diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 43c4f9671..0cbfcc909 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -317,6 +317,15 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge max_value=self.validation_maximum ) + # Decimal + if self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + field = forms.DecimalField( + required=required, + initial=initial, + max_digits=10, + decimal_places=2, + ) + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( From b23ac303cd2b47622dff0f906b48177365d1a41e Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 08:50:20 -0700 Subject: [PATCH 15/19] 10348 add decimal custom field --- netbox/extras/models/customfields.py | 4 +-- netbox/extras/tests/test_customfields.py | 42 ++++++++++++++++++++++++ netbox/extras/tests/test_forms.py | 3 ++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 0cbfcc909..170509ec4 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -219,11 +219,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge }) # Minimum/maximum values can be set only for numeric fields - if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: + if self.validation_minimum is not None and (self.type != CustomFieldTypeChoices.TYPE_INTEGER and self.type != CustomFieldTypeChoices.TYPE_DECIMAL): raise ValidationError({ 'validation_minimum': "A minimum value may be set only for numeric fields" }) - if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: + if self.validation_maximum is not None and (self.type != CustomFieldTypeChoices.TYPE_INTEGER and self.type != CustomFieldTypeChoices.TYPE_DECIMAL): raise ValidationError({ 'validation_maximum': "A maximum value may be set only for numeric fields" }) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 946999bc2..4aa63defc 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -102,6 +102,32 @@ class CustomFieldTest(TestCase): instance.refresh_from_db() self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_decimal_field(self): + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='decimal_field', + type=CustomFieldTypeChoices.TYPE_DECIMAL, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (123456.54, 0, -123456.78): + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_boolean_field(self): # Create a custom field & check that initial value is null @@ -1096,6 +1122,11 @@ class CustomFieldModelFilterTest(TestCase): cf.save() cf.content_types.set([obj_type]) + # Decimal filtering + cf = CustomField(name='cf12', type=CustomFieldTypeChoices.TYPE_DECIMAL) + cf.save() + cf.content_types.set([obj_type]) + Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', custom_field_data={ 'cf1': 100, @@ -1109,6 +1140,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf9': ['A', 'X'], 'cf10': manufacturers[0].pk, 'cf11': [manufacturers[0].pk, manufacturers[3].pk], + 'cf12': 100.25, }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, @@ -1122,6 +1154,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf9': ['B', 'X'], 'cf10': manufacturers[1].pk, 'cf11': [manufacturers[1].pk, manufacturers[3].pk], + 'cf12': 200.25, }), Site(name='Site 3', slug='site-3', custom_field_data={ 'cf1': 300, @@ -1135,6 +1168,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf9': ['C', 'X'], 'cf10': manufacturers[2].pk, 'cf11': [manufacturers[2].pk, manufacturers[3].pk], + 'cf12': 300.25, }), ]) @@ -1146,6 +1180,14 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) + def test_filter_decimal(self): + self.assertEqual(self.filterset({'cf_cf12': [100.25, 200.25]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf12__n': [200.25]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf12__gt': [200.25]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf12__gte': [200.25]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf12__lt': [200.25]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf12__lte': [200.25]}, self.queryset).qs.count(), 2) + def test_filter_boolean(self): self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 1ec50b7dd..35402bda3 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -23,6 +23,9 @@ class CustomFieldModelFormTest(TestCase): cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) cf_integer.content_types.set([obj_type]) + cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL) + cf_integer.content_types.set([obj_type]) + cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf_boolean.content_types.set([obj_type]) From 3b156a533080492d216e617424026374c0e6ae14 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 10:31:43 -0700 Subject: [PATCH 16/19] 10348 add decimal custom field --- netbox/extras/models/customfields.py | 16 +++++++++++- netbox/extras/tests/test_customfields.py | 32 +++++++++++++++++++----- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 170509ec4..5a7e21863 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,5 +1,6 @@ import re from datetime import datetime, date +import decimal import django_filters from django import forms @@ -318,7 +319,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge ) # Decimal - if self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: field = forms.DecimalField( required=required, initial=initial, @@ -435,6 +436,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: filter_class = filters.MultiValueNumberFilter + # Decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + filter_class = filters.MultiValueNumberFilter + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: filter_class = django_filters.BooleanFilter @@ -492,6 +497,15 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge if self.validation_maximum is not None and value > self.validation_maximum: raise ValidationError(f"Value must not exceed {self.validation_maximum}") + # Validate decimal + if self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + if type(value) is not decimal.Decimal: + raise ValidationError("Value must be a decimal.") + if self.validation_minimum is not None and value < self.validation_minimum: + raise ValidationError(f"Value must be at least {self.validation_minimum}") + if self.validation_maximum is not None and value > self.validation_maximum: + raise ValidationError(f"Value must not exceed {self.validation_maximum}") + # Validate boolean if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: raise ValidationError("Value must be true or false.") diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 4aa63defc..435af89d9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -432,6 +432,7 @@ class CustomFieldAPITest(APITestCase): object_type=ContentType.objects.get_for_model(VLAN), default=[vlans[0].pk, vlans[1].pk], ), + CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45), ) for cf in custom_fields: cf.save() @@ -458,6 +459,7 @@ class CustomFieldAPITest(APITestCase): custom_fields[8].name: ['Bar', 'Baz'], custom_fields[9].name: vlans[1].pk, custom_fields[10].name: [vlans[2].pk, vlans[3].pk], + custom_fields[11].name: 456.78, } sites[1].save() @@ -474,6 +476,7 @@ class CustomFieldAPITest(APITestCase): CustomFieldTypeChoices.TYPE_MULTISELECT: 'array', CustomFieldTypeChoices.TYPE_OBJECT: 'object', CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array', + CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal', } self.add_permissions('extras.view_customfield') @@ -508,6 +511,7 @@ class CustomFieldAPITest(APITestCase): 'multiselect_field': None, 'object_field': None, 'multiobject_field': None, + 'decimal_field': None, }) def test_get_single_object_with_custom_field_data(self): @@ -535,6 +539,7 @@ class CustomFieldAPITest(APITestCase): [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], site2_cfvs['multiobject_field'] ) + self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field']) def test_create_single_object_with_defaults(self): """ @@ -569,6 +574,7 @@ class CustomFieldAPITest(APITestCase): [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], cf_defaults['multiobject_field'] ) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -583,6 +589,7 @@ class CustomFieldAPITest(APITestCase): 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']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) def test_create_single_object_with_values(self): """ @@ -603,6 +610,7 @@ class CustomFieldAPITest(APITestCase): '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)), + 'decimal_field': 456.78, }, } url = reverse('dcim-api:site-list') @@ -628,6 +636,7 @@ class CustomFieldAPITest(APITestCase): [obj['id'] for obj in response_cf['multiobject_field']], data_cf['multiobject_field'] ) + self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -642,6 +651,7 @@ class CustomFieldAPITest(APITestCase): 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']) + self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field']) def test_create_multiple_objects_with_defaults(self): """ @@ -690,6 +700,7 @@ class CustomFieldAPITest(APITestCase): [obj['id'] for obj in response_cf['multiobject_field']], cf_defaults['multiobject_field'] ) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -704,6 +715,7 @@ class CustomFieldAPITest(APITestCase): 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']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) def test_create_multiple_objects_with_values(self): """ @@ -721,6 +733,7 @@ class CustomFieldAPITest(APITestCase): '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)), + 'decimal_field': 456.78, } data = ( { @@ -764,6 +777,7 @@ class CustomFieldAPITest(APITestCase): [obj['id'] for obj in response_cf['multiobject_field']], custom_field_data['multiobject_field'] ) + self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field']) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -778,6 +792,7 @@ class CustomFieldAPITest(APITestCase): 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']) + self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field']) def test_update_single_object_with_values(self): """ @@ -814,6 +829,7 @@ class CustomFieldAPITest(APITestCase): [obj['id'] for obj in response_cf['multiobject_field']], original_cfvs['multiobject_field'] ) + self.assertEqual(response_cf['decimal_field'], data['custom_fields']['decimal_field']) # Validate database data site2.refresh_from_db() @@ -828,6 +844,7 @@ class CustomFieldAPITest(APITestCase): 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']) + self.assertEqual(site2.custom_field_data['decimal_field'], data['custom_fields']['decimal_field']) def test_minimum_maximum_values_validation(self): site2 = Site.objects.get(name='Site 2') @@ -896,6 +913,7 @@ class CustomFieldImportTest(TestCase): CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[ 'Choice A', 'Choice B', 'Choice C', ]), + CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL), ) for cf in custom_fields: cf.save() @@ -906,10 +924,10 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), - ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), - ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''), + ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect', 'cf_decimal'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"', '123.45'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"', '456.78'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) @@ -919,7 +937,7 @@ class CustomFieldImportTest(TestCase): # Validate data for site 1 site1 = Site.objects.get(name='Site 1') - self.assertEqual(len(site1.custom_field_data), 9) + self.assertEqual(len(site1.custom_field_data), 10) self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['integer'], 123) @@ -929,10 +947,11 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site1.custom_field_data['json'], {"foo": 123}) self.assertEqual(site1.custom_field_data['select'], 'Choice A') self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B']) + self.assertEqual(site1.custom_field_data['decimal'], '123.45') # Validate data for site 2 site2 = Site.objects.get(name='Site 2') - self.assertEqual(len(site2.custom_field_data), 9) + self.assertEqual(len(site2.custom_field_data), 10) self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['integer'], 456) @@ -942,6 +961,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.custom_field_data['json'], {"bar": 456}) self.assertEqual(site2.custom_field_data['select'], 'Choice B') self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C']) + self.assertEqual(site2.custom_field_data['decimal'], '456.78') # No custom field data should be set for site 3 site3 = Site.objects.get(name='Site 3') From 298eda1fa8eaeefa876c137484802c12a734e0a0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 11:26:33 -0700 Subject: [PATCH 17/19] 10348 add decimal custom field --- netbox/extras/models/customfields.py | 3 +-- netbox/extras/tests/test_customfields.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 5a7e21863..85d703012 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,6 +1,5 @@ import re from datetime import datetime, date -import decimal import django_filters from django import forms @@ -499,7 +498,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge # Validate decimal if self.type == CustomFieldTypeChoices.TYPE_DECIMAL: - if type(value) is not decimal.Decimal: + if type(value) is not str: raise ValidationError("Value must be a decimal.") if self.validation_minimum is not None and value < self.validation_minimum: raise ValidationError(f"Value must be at least {self.validation_minimum}") diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 435af89d9..22411d436 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -432,7 +432,7 @@ class CustomFieldAPITest(APITestCase): object_type=ContentType.objects.get_for_model(VLAN), default=[vlans[0].pk, vlans[1].pk], ), - CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45), + CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default='123.45'), ) for cf in custom_fields: cf.save() From 70fc4a13dad1c6ab2d5ac6f5e88785f30f91e242 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 13:13:54 -0700 Subject: [PATCH 18/19] 10348 add decimal custom field --- netbox/extras/models/customfields.py | 21 ++++++++++++--------- netbox/extras/tests/test_customfields.py | 6 +++--- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 85d703012..6a5c682bc 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,5 +1,6 @@ import re from datetime import datetime, date +import decimal import django_filters from django import forms @@ -488,7 +489,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge raise ValidationError(f"Value must match regex '{self.validation_regex}'") # Validate integer - if self.type == CustomFieldTypeChoices.TYPE_INTEGER: + elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: if type(value) is not int: raise ValidationError("Value must be an integer.") if self.validation_minimum is not None and value < self.validation_minimum: @@ -497,20 +498,22 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge raise ValidationError(f"Value must not exceed {self.validation_maximum}") # Validate decimal - if self.type == CustomFieldTypeChoices.TYPE_DECIMAL: - if type(value) is not str: + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + if type(value) is not decimal.Decimal: raise ValidationError("Value must be a decimal.") - if self.validation_minimum is not None and value < self.validation_minimum: + + converted = decimal.Decimal(value) + if self.validation_minimum is not None and converted < self.validation_minimum: raise ValidationError(f"Value must be at least {self.validation_minimum}") - if self.validation_maximum is not None and value > self.validation_maximum: + if self.validation_maximum is not None and converted > self.validation_maximum: raise ValidationError(f"Value must not exceed {self.validation_maximum}") # Validate boolean - if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: + elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: raise ValidationError("Value must be true or false.") # Validate date - if self.type == CustomFieldTypeChoices.TYPE_DATE: + elif self.type == CustomFieldTypeChoices.TYPE_DATE: if type(value) is not date: try: datetime.strptime(value, '%Y-%m-%d') @@ -518,14 +521,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge raise ValidationError("Date values must be in the format YYYY-MM-DD.") # Validate selected choice - if self.type == CustomFieldTypeChoices.TYPE_SELECT: + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: if value not in self.choices: raise ValidationError( f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" ) # Validate all selected choices - if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: if not set(value).issubset(self.choices): raise ValidationError( f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 22411d436..2efb4a873 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -476,7 +476,7 @@ class CustomFieldAPITest(APITestCase): CustomFieldTypeChoices.TYPE_MULTISELECT: 'array', CustomFieldTypeChoices.TYPE_OBJECT: 'object', CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array', - CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal', + CustomFieldTypeChoices.TYPE_git: 'decimal', } self.add_permissions('extras.view_customfield') @@ -1201,8 +1201,8 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) def test_filter_decimal(self): - self.assertEqual(self.filterset({'cf_cf12': [100.25, 200.25]}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf12__n': [200.25]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf12': [100.25, 200.25]}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf12__n': [200.25]}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf12__gt': [200.25]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf12__gte': [200.25]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf12__lt': [200.25]}, self.queryset).qs.count(), 1) From 91ec21063a5a227d18a8a52245e57b073004f13c Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 15:40:43 -0700 Subject: [PATCH 19/19] 10348 add decimal custom field --- netbox/extras/models/customfields.py | 4 ++-- netbox/extras/tests/test_customfields.py | 13 +++++++------ netbox/utilities/filters.py | 6 +++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 6a5c682bc..56cbf6b4e 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -438,7 +438,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge # Decimal elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: - filter_class = filters.MultiValueNumberFilter + filter_class = filters.MultiValueDecimalFilter # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: @@ -499,7 +499,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge # Validate decimal elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: - if type(value) is not decimal.Decimal: + if type(value) is not decimal.Decimal and type(value) is not str: raise ValidationError("Value must be a decimal.") converted = decimal.Decimal(value) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 2efb4a873..2754cf1bf 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,3 +1,4 @@ +import decimal from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse @@ -459,7 +460,7 @@ class CustomFieldAPITest(APITestCase): custom_fields[8].name: ['Bar', 'Baz'], custom_fields[9].name: vlans[1].pk, custom_fields[10].name: [vlans[2].pk, vlans[3].pk], - custom_fields[11].name: 456.78, + custom_fields[11].name: '456.78', } sites[1].save() @@ -476,7 +477,7 @@ class CustomFieldAPITest(APITestCase): CustomFieldTypeChoices.TYPE_MULTISELECT: 'array', CustomFieldTypeChoices.TYPE_OBJECT: 'object', CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array', - CustomFieldTypeChoices.TYPE_git: 'decimal', + CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal', } self.add_permissions('extras.view_customfield') @@ -610,7 +611,7 @@ class CustomFieldAPITest(APITestCase): '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)), - 'decimal_field': 456.78, + 'decimal_field': '456.78', }, } url = reverse('dcim-api:site-list') @@ -733,7 +734,7 @@ class CustomFieldAPITest(APITestCase): '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)), - 'decimal_field': 456.78, + 'decimal_field': '456.78', } data = ( { @@ -829,7 +830,7 @@ class CustomFieldAPITest(APITestCase): [obj['id'] for obj in response_cf['multiobject_field']], original_cfvs['multiobject_field'] ) - self.assertEqual(response_cf['decimal_field'], data['custom_fields']['decimal_field']) + self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field']) # Validate database data site2.refresh_from_db() @@ -844,7 +845,7 @@ class CustomFieldAPITest(APITestCase): 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']) - self.assertEqual(site2.custom_field_data['decimal_field'], data['custom_fields']['decimal_field']) + self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_field']) def test_minimum_maximum_values_validation(self): site2 = Site.objects.get(name='Site 2') diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 543449b73..f73b6dacc 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -23,7 +23,7 @@ def multivalue_field_factory(field_class): field.to_python(v) for v in value if v ] - return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) + return type(f'MultiValue{field_class.__name__}', (NewField,), dict()) # @@ -46,6 +46,10 @@ class MultiValueNumberFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.IntegerField) +class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DecimalField) + + class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.TimeField)