From 27d72746ca4aec377001cfc69fa416bf4bd652dc Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 8 Sep 2022 13:20:27 -0700 Subject: [PATCH 001/179] #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 363f97b3167..22106587d0c 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 ddbf07b9b31..37659c5233f 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 002/179] #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 838336e215d..e22913a8ba0 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -953,12 +953,13 @@ def clean(self): 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 ccf4613bff1..7858960a1f7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -268,7 +268,7 @@ def clean(self): 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 abad57f888a..4acbe6daf38 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -367,7 +367,7 @@ def clean(self): }) # 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 003/179] 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 c58621b81df..16f5dd0df5f 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 00000000000..88bc7810e9a --- /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 530c6d52eed..8f6e2930a30 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 7f30248b433..9fa1c5cef5f 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -92,8 +92,17 @@ class Meta: 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 004/179] 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 a62d14feff3..15f74375478 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 a6aa27b1b7e..583a4f3e9d8 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 8b588fccdab..49163550dbb 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 802c13e4924..cc49cd30ec3 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 cffcb37dd54..fe82e68aa71 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 88bc7810e9a..39c44f38ebc 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 824b4e0923f5d215153cf8623c5f20fc7190f857 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sun, 18 Sep 2022 15:06:28 +0200 Subject: [PATCH 005/179] Add scheduling for reports and scripts --- netbox/extras/forms/reports.py | 16 ++++++++++++++++ netbox/extras/forms/scripts.py | 16 ++++++++++++---- netbox/extras/models/models.py | 6 +++++- netbox/extras/views.py | 14 ++++++++++++-- netbox/project-static/package.json | 2 +- netbox/templates/extras/report.html | 25 ++++++++++++++++--------- netbox/templates/extras/script.html | 2 +- 7 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 netbox/extras/forms/reports.py diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py new file mode 100644 index 00000000000..658796bb566 --- /dev/null +++ b/netbox/extras/forms/reports.py @@ -0,0 +1,16 @@ +from django import forms + +from utilities.forms import BootstrapMixin, DateTimePicker + +__all__ = ( + 'ReportForm', +) + + +class ReportForm(BootstrapMixin, forms.Form): + schedule_at = forms.DateTimeField( + required=False, + widget=DateTimePicker(), + label="Schedule at", + help_text="Schedule execution of report to a set time", + ) \ No newline at end of file diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 380b4364c55..de55a3ee66f 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -1,6 +1,6 @@ from django import forms -from utilities.forms import BootstrapMixin +from utilities.forms import BootstrapMixin, DateTimePicker __all__ = ( 'ScriptForm', @@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form): label="Commit changes", help_text="Commit changes to the database (uncheck for a dry-run)" ) + _schedule_at = forms.DateTimeField( + required=False, + widget=DateTimePicker(), + label="Schedule at", + help_text="Schedule execution of script to a set time", + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Move _commit to the end of the form + # Move _commit and _schedule_at to the end of the form + schedule_at = self.fields.pop('_schedule_at') commit = self.fields.pop('_commit') + self.fields['_schedule_at'] = schedule_at self.fields['_commit'] = commit @property def requires_input(self): """ - A boolean indicating whether the form requires user input (ignore the _commit field). + A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields). """ - return bool(len(self.fields) > 1) + return bool(len(self.fields) > 2) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 4873a1f9e76..1d3c142c749 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -550,7 +550,11 @@ def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs): ) queue = django_rq.get_queue("default") - queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) + + if schedule_at := kwargs.pop("schedule_at", None): + queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) + else: + queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) return job_result diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 5b589c1814e..073496773af 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -15,6 +15,7 @@ from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables from .choices import JobResultStatusChoices +from .forms.reports import ReportForm from .models import * from .reports import get_report, get_reports, run_report from .scripts import get_scripts, run_script @@ -562,7 +563,7 @@ def get(self, request, module, name): return render(request, 'extras/report.html', { 'report': report, - 'run_form': ConfirmationForm(), + 'form': ReportForm(), }) def post(self, request, module, name): @@ -575,6 +576,12 @@ def post(self, request, module, name): if report is None: raise Http404 + schedule_at = None + form = ReportForm(request.POST) + + if form.is_valid(): + schedule_at = form.cleaned_data.get("schedule_at") + # Allow execution only if RQ worker process is running if not Worker.count(get_connection('default')): messages.error(request, "Unable to run report: RQ worker process not running.") @@ -589,7 +596,8 @@ def post(self, request, module, name): report.full_name, report_content_type, request.user, - job_timeout=report.job_timeout + job_timeout=report.job_timeout, + schedule_at=schedule_at, ) return redirect('extras:report_result', job_result_pk=job_result.pk) @@ -707,6 +715,7 @@ def post(self, request, module, name): elif form.is_valid(): commit = form.cleaned_data.pop('_commit') + schedule_at = form.cleaned_data.pop("_schedule_at") script_content_type = ContentType.objects.get(app_label='extras', model='script') @@ -719,6 +728,7 @@ def post(self, request, module, name): request=copy_safe_request(request), commit=commit, job_timeout=script.job_timeout, + schedule_at=schedule_at, ) return redirect('extras:script_result', job_result_pk=job_result.pk) diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 2566969477f..808a49825c0 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -28,7 +28,7 @@ "clipboard": "^2.0.8", "color2k": "^1.2.4", "dayjs": "^1.10.4", - "flatpickr": "4.6.3", + "flatpickr": "4.6.13", "htmx.org": "^1.6.1", "just-debounce-it": "^1.4.0", "masonry-layout": "^4.2.2", diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 391de6614fd..94f37571bc4 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -1,5 +1,6 @@ {% extends 'generic/object.html' %} {% load helpers %} +{% load form_helpers %} {% block title %}{{ report.name }}{% endblock %} @@ -33,18 +34,24 @@ {% block content %}
{% if perms.extras.run_report %} -
-
+
+
+ {% csrf_token %} - + {% render_form form %} +
+ +
+
+ {% endif %}
diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 99eade0a09e..6fbcde32252 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -43,7 +43,7 @@ You do not have permission to run scripts.
{% endif %} -
+ {% csrf_token %}
{% if form.requires_input %} From c8671ce8e823bcd450855c37a0a237008cfca69f Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Tue, 20 Sep 2022 08:53:57 +0200 Subject: [PATCH 006/179] Update yarn.lock (flatpickr bump) --- netbox/project-static/yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 8e21446c6a7..d0b4384536a 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -1368,10 +1368,10 @@ flat-cache@^3.0.4: flatted "^3.1.0" rimraf "^3.0.2" -flatpickr@4.6.3: - version "4.6.3" - resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.3.tgz#15a8b76b6e34e3a072861250503a5995b9d3bc60" - integrity sha512-007VucCkqNOMMb9ggRLNuJowwaJcyOh4sKAFcdGfahfGc7JQbf94zSzjdBq/wVyHWUEs5o3+idhFZ0wbZMRmVQ== +flatpickr@4.6.13: + version "4.6.13" + resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.13.tgz#8a029548187fd6e0d670908471e43abe9ad18d94" + integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw== flatted@^3.1.0: version "3.2.2" From 5d07f2c837a140b316598206fa76ead1792a3fde Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 16:03:55 -0700 Subject: [PATCH 007/179] 9892 add FHRP group name --- netbox/ipam/migrations/0061_fhrpgroup_name.py | 18 ++++++++++++++++++ netbox/ipam/models/fhrp.py | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 netbox/ipam/migrations/0061_fhrpgroup_name.py diff --git a/netbox/ipam/migrations/0061_fhrpgroup_name.py b/netbox/ipam/migrations/0061_fhrpgroup_name.py new file mode 100644 index 00000000000..7e232c18fe3 --- /dev/null +++ b/netbox/ipam/migrations/0061_fhrpgroup_name.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.7 on 2022-09-20 23:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0060_alter_l2vpn_slug'), + ] + + operations = [ + migrations.AddField( + model_name='fhrpgroup', + name='name', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 28625144405..b022386c84c 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -22,6 +22,10 @@ class FHRPGroup(NetBoxModel): group_id = models.PositiveSmallIntegerField( verbose_name='Group ID' ) + name = models.CharField( + max_length=100, + blank=True + ) protocol = models.CharField( max_length=50, choices=FHRPGroupProtocolChoices From a5421ae1707142c0ceb74399757df765a2a9f8d4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 20 Sep 2022 16:13:01 -0700 Subject: [PATCH 008/179] 9892 add FHRP group name --- netbox/ipam/filtersets.py | 2 +- netbox/ipam/tables/fhrp.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 3c0ab1ac863..1eb1ee8638c 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -653,7 +653,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): class Meta: model = FHRPGroup - fields = ['id', 'group_id', 'auth_key'] + fields = ['id', 'group_id', 'name', 'auth_key'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index f709bfeb220..dc4982e269c 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -36,7 +36,7 @@ class FHRPGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', + 'pk', 'name', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') From e074570b8fd8eac213b49750360982199043153c Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 22 Sep 2022 10:01:19 -0700 Subject: [PATCH 009/179] 9071 add header to plugin menu --- netbox/extras/plugins/__init__.py | 14 ++++++++++--- netbox/netbox/navigation_menu.py | 34 ++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 0b57e6f0520..95e88ca8ced 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -58,6 +58,7 @@ class PluginConfig(AppConfig): # integrated components. graphql_schema = 'graphql.schema' menu_items = 'navigation.menu_items' + menu_header = 'navigation.menu_heading' template_extensions = 'template_content.template_extensions' user_preferences = 'preferences.preferences' @@ -70,9 +71,14 @@ def ready(self): register_template_extensions(template_extensions) # Register navigation menu items (if defined) + try: + menu_header = import_object(f"{self.__module__}.{self.menu_header}") + except AttributeError: + menu_header = None + menu_items = import_object(f"{self.__module__}.{self.menu_items}") if menu_items is not None: - register_menu_items(self.verbose_name, menu_items) + register_menu_items(self.verbose_name, menu_header, menu_items) # Register GraphQL schema (if defined) graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}") @@ -246,7 +252,7 @@ def __init__(self, link, title, icon_class, color=None, permissions=None): self.color = color -def register_menu_items(section_name, class_list): +def register_menu_items(section_name, menu_header, class_list): """ Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) """ @@ -258,7 +264,9 @@ def register_menu_items(section_name, class_list): if not isinstance(button, PluginMenuButton): raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - registry['plugins']['menu_items'][section_name] = class_list + registry['plugins']['menu_items'][section_name] = {} + registry['plugins']['menu_items'][section_name]['header'] = menu_header + registry['plugins']['menu_items'][section_name]['items'] = class_list # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a495f17c94f..d4970aa3561 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -408,18 +408,28 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')): if registry['plugins']['menu_items']: plugin_menu_groups = [] - for plugin_name, items in registry['plugins']['menu_items'].items(): - plugin_menu_groups.append( - MenuGroup( - label=plugin_name, - items=items + for plugin_name, data in registry['plugins']['menu_items'].items(): + if data['header']: + menu_groups = [MenuGroup(label=plugin_name, items=data["items"])] + icon = data["header"]["icon"] + MENUS.append(Menu( + label=data["header"]["title"], + icon_class=f"mdi {icon}", + groups=menu_groups + )) + else: + plugin_menu_groups.append( + MenuGroup( + label=plugin_name, + items=data["items"] + ) ) - ) - PLUGIN_MENU = Menu( - label="Plugins", - icon_class="mdi mdi-puzzle", - groups=plugin_menu_groups - ) + if plugin_menu_groups: + PLUGIN_MENU = Menu( + label="Plugins", + icon_class="mdi mdi-puzzle", + groups=plugin_menu_groups + ) - MENUS.append(PLUGIN_MENU) + MENUS.append(PLUGIN_MENU) From cbb3378d10adc6ca72de64c976bb6b4d5385444c Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 23 Sep 2022 06:45:40 +0200 Subject: [PATCH 010/179] Job Scheduling WIP --- netbox/extras/choices.py | 2 ++ netbox/extras/filtersets.py | 21 +++++++++++++++++++++ netbox/extras/forms/filtersets.py | 8 ++++++++ netbox/extras/models/models.py | 11 ++++++++++- netbox/extras/tables/tables.py | 25 +++++++++++++++++++++++++ netbox/extras/urls.py | 4 ++++ netbox/extras/views.py | 11 +++++++++++ 7 files changed, 81 insertions(+), 1 deletion(-) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 123fd2cd4f5..fe0f0eed5f6 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -139,6 +139,7 @@ class LogLevelChoices(ChoiceSet): class JobResultStatusChoices(ChoiceSet): STATUS_PENDING = 'pending' + STATUS_SCHEDULED = 'pending' STATUS_RUNNING = 'running' STATUS_COMPLETED = 'completed' STATUS_ERRORED = 'errored' @@ -146,6 +147,7 @@ class JobResultStatusChoices(ChoiceSet): CHOICES = ( (STATUS_PENDING, 'Pending'), + (STATUS_SCHEDULED, 'Pending'), (STATUS_RUNNING, 'Running'), (STATUS_COMPLETED, 'Completed'), (STATUS_ERRORED, 'Errored'), diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index df0af3541b7..25f1a04c98b 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -16,6 +16,7 @@ 'ConfigContextFilterSet', 'ContentTypeFilterSet', 'CustomFieldFilterSet', + 'JobResultFilterSet', 'CustomLinkFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', @@ -86,6 +87,26 @@ def search(self, queryset, name, value): Q(description__icontains=value) ) +class JobResultFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + # TODO: Add filters + + class Meta: + model = JobResult + fields = [ + 'id', 'name', 'obj_type', 'created', 'completed', 'user', 'status' + ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) + ) class CustomLinkFilterSet(BaseFilterSet): q = django_filters.CharFilter( diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 526d47013aa..2e715d9a705 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -19,6 +19,7 @@ __all__ = ( 'ConfigContextFilterForm', 'CustomFieldFilterForm', + 'JobResultFilterForm', 'CustomLinkFilterForm', 'ExportTemplateFilterForm', 'JournalEntryFilterForm', @@ -65,6 +66,13 @@ class CustomFieldFilterForm(FilterForm): ) +class JobResultFilterForm(FilterForm): + fieldsets = ( + (None, ('q',)), + #('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), + ) + + class CustomLinkFilterForm(FilterForm): fieldsets = ( (None, ('q',)), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 100766d5330..574b51f70f2 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -509,12 +509,18 @@ class JobResult(models.Model): unique=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ['obj_type', 'name', '-created'] def __str__(self): return str(self.job_id) + def get_absolute_url(self): + # TODO: Fix this to point the right place + return reverse('virtualization:clustertype', args=[self.pk]) + @property def duration(self): if not self.completed: @@ -546,7 +552,7 @@ def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs): args: additional args passed to the callable kwargs: additional kargs passed to the callable """ - job_result = cls.objects.create( + job_result: JobResult = cls.objects.create( name=name, obj_type=obj_type, user=user, @@ -556,6 +562,9 @@ def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs): queue = django_rq.get_queue("default") if schedule_at := kwargs.pop("schedule_at", None): + job_result.status = JobResultStatusChoices.STATUS_SCHEDULED + job_result.save() + queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) else: queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 1df5c948724..60e500da663 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -8,6 +8,7 @@ __all__ = ( 'ConfigContextTable', 'CustomFieldTable', + 'JobResultTable', 'CustomLinkTable', 'ExportTemplateTable', 'JournalEntryTable', @@ -39,6 +40,30 @@ class Meta(NetBoxTable.Meta): default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') +# +# Custom fields +# + +class JobResultTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + #obj_type = columns.ContentTypesColumn() + required = columns.BooleanColumn() + ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") + + actions = columns.ActionsColumn( + actions=() # TODO: Delete + ) + + class Meta(NetBoxTable.Meta): + model = JobResult + fields = ( + 'pk', 'id', 'name', 'obj_type', 'created', 'completed', 'user', 'status', 'job_id', + ) + default_columns = ('pk', 'id', 'name', 'obj_type', 'created', 'completed', 'user', 'status', 'job_id') + + # # Custom links # diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ced3bd4b94f..66d60133ffd 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -103,6 +103,10 @@ path('reports/results//', views.ReportResultView.as_view(), name='report_result'), re_path(r'^reports/(?P.([^.]+)).(?P.(.+))/', views.ReportView.as_view(), name='report'), + # Job results + path('job-results/', views.JobResultListView.as_view(), name='jobresult_view'), + # path('custom-fields//', views.CustomFieldView.as_view(), name='customfield'), + # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 21bffdff3c8..bf122955ec0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -775,3 +775,14 @@ def get(self, request, job_result_pk): 'result': result, 'class_name': script.__class__.__name__ }) + + +# +# Job results +# + +class JobResultListView(generic.ObjectListView): + queryset = JobResult.objects.all() + filterset = filtersets.JobResultFilterSet + filterset_form = forms.JobResultFilterForm + table = tables.JobResultTable \ No newline at end of file From 06dea8ef3f53b08c85f4ac17c04f71520d868bb3 Mon Sep 17 00:00:00 2001 From: kkthxbye-code <> Date: Fri, 23 Sep 2022 13:44:24 +0200 Subject: [PATCH 011/179] WIP: Moving JobResults out of the admin panel --- netbox/extras/filtersets.py | 24 +---------- netbox/extras/models/models.py | 5 +-- netbox/extras/tables/tables.py | 5 +-- netbox/extras/urls.py | 6 ++- netbox/extras/views.py | 14 ++++++ netbox/netbox/navigation_menu.py | 5 +++ netbox/templates/extras/jobresult.html | 60 ++++++++++++++++++++++++++ 7 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 netbox/templates/extras/jobresult.html diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 25f1a04c98b..aed8d4bb3a2 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -87,27 +87,6 @@ def search(self, queryset, name, value): Q(description__icontains=value) ) -class JobResultFilterSet(BaseFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) - - # TODO: Add filters - - class Meta: - model = JobResult - fields = [ - 'id', 'name', 'obj_type', 'created', 'completed', 'user', 'status' - ] - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) - ) - class CustomLinkFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -472,7 +451,8 @@ def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(user__username__icontains=value) + Q(user__username__icontains=value) | + Q(name__icontains=value) ) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 574b51f70f2..51cd30dbf6f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -518,8 +518,7 @@ def __str__(self): return str(self.job_id) def get_absolute_url(self): - # TODO: Fix this to point the right place - return reverse('virtualization:clustertype', args=[self.pk]) + return reverse('extras:jobresult', args=[self.pk]) @property def duration(self): @@ -560,7 +559,7 @@ def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs): ) queue = django_rq.get_queue("default") - + if schedule_at := kwargs.pop("schedule_at", None): job_result.status = JobResultStatusChoices.STATUS_SCHEDULED job_result.save() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 60e500da663..39f186ed6bc 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -48,12 +48,9 @@ class JobResultTable(NetBoxTable): name = tables.Column( linkify=True ) - #obj_type = columns.ContentTypesColumn() - required = columns.BooleanColumn() - ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") actions = columns.ActionsColumn( - actions=() # TODO: Delete + actions=('delete',) ) class Meta(NetBoxTable.Meta): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 66d60133ffd..1d42211c4be 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -104,8 +104,10 @@ re_path(r'^reports/(?P.([^.]+)).(?P.(.+))/', views.ReportView.as_view(), name='report'), # Job results - path('job-results/', views.JobResultListView.as_view(), name='jobresult_view'), - # path('custom-fields//', views.CustomFieldView.as_view(), name='customfield'), + path('job-results/', views.JobResultListView.as_view(), name='jobresult_list'), + path('job-results//', views.JobResultView.as_view(), name='jobresult'), + path('job-results/delete/', views.JobResultBulkDeleteView.as_view(), name='jobresult_bulk_delete'), + path('job-results//delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'), # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index bf122955ec0..8ffa1ce29e9 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -781,8 +781,22 @@ def get(self, request, job_result_pk): # Job results # +class JobResultView(generic.ObjectView): + queryset = JobResult.objects.all() + + class JobResultListView(generic.ObjectListView): queryset = JobResult.objects.all() filterset = filtersets.JobResultFilterSet filterset_form = forms.JobResultFilterForm + table = tables.JobResultTable + actions = ('delete', 'bulk_delete', ) + +class JobResultDeleteView(generic.ObjectDeleteView): + queryset = JobResult.objects.all() + + +class JobResultBulkDeleteView(generic.BulkDeleteView): + queryset = JobResult.objects.all() + filterset = filtersets.JobResultFilterSet table = tables.JobResultTable \ No newline at end of file diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a495f17c94f..e82c07ca084 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -375,6 +375,11 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')): link_text='Scripts', permissions=['extras.view_script'] ), + MenuItem( + link='extras:jobresult_list', + link_text='Job Results', + permissions=['extras.view_jobresult'], + ), ), ), MenuGroup( diff --git a/netbox/templates/extras/jobresult.html b/netbox/templates/extras/jobresult.html new file mode 100644 index 00000000000..5f46dd6acf7 --- /dev/null +++ b/netbox/templates/extras/jobresult.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block title %}{{ object.name }} ({{object.job_id}}){% endblock %} + +{# JobResult does not support add/edit controls #} +{% block controls %}{% endblock %} +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
+ Tag +
+
+ + + + + + + + + + + + + +
Name + {{ object.name }} +
Created + {{ object.created|annotated_date }} +
Completed + {{ object.completed|annotated_date }} +
+
+
+
+
+
+
+ TODO +
+
+ + TODO +
+
+
+
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} From e11d5941222312cde96429b411d04c88db8f300c Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 11:39:43 -0700 Subject: [PATCH 012/179] 9892 add supporting tests, form fields --- docs/models/ipam/fhrpgroup.md | 8 ++++++++ netbox/ipam/api/serializers.py | 2 +- netbox/ipam/filtersets.py | 3 ++- netbox/ipam/forms/bulk_edit.py | 4 ++-- netbox/ipam/forms/bulk_import.py | 2 +- netbox/ipam/forms/filtersets.py | 5 ++++- netbox/ipam/forms/models.py | 4 ++-- netbox/ipam/tables/fhrp.py | 2 +- netbox/ipam/tests/test_api.py | 1 + netbox/ipam/tests/test_filtersets.py | 6 +++++- netbox/ipam/tests/test_views.py | 1 + netbox/templates/ipam/fhrpgroup.html | 4 ++++ netbox/templates/ipam/fhrpgroup_edit.html | 1 + 13 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md index 4da390310eb..ddb3010e266 100644 --- a/docs/models/ipam/fhrpgroup.md +++ b/docs/models/ipam/fhrpgroup.md @@ -19,6 +19,14 @@ The wire protocol employed by cooperating servers to maintain the virtual [IP ad The group's numeric identifier. +### Name + +An optional name for the FHRP group. + +### Description + +A brief description of the FHRP group. + ### Authentication Type The type of authentication employed by group nodes, if any. diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index fa8b563e95d..8a8fc03d636 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -123,7 +123,7 @@ class FHRPGroupSerializer(NetBoxModelSerializer): class Meta: model = FHRPGroup fields = [ - 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 1eb1ee8638c..360cf2a56f8 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -659,7 +659,8 @@ def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(description__icontains=value) + Q(description__icontains=value) | + Q(name__icontains=value) ) def filter_related_ip(self, queryset, name, value): diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 5f579b07f0b..7746b6625bc 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -328,10 +328,10 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): model = FHRPGroup fieldsets = ( - (None, ('protocol', 'group_id', 'description')), + (None, ('protocol', 'group_id', 'name', 'description')), ('Authentication', ('auth_type', 'auth_key')), ) - nullable_fields = ('auth_type', 'auth_key', 'description') + nullable_fields = ('auth_type', 'auth_key', 'name', 'description') class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 880d2722f47..6a9dd91aca0 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -326,7 +326,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm): class Meta: model = FHRPGroup - fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description') + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description') class VLANGroupCSVForm(NetBoxModelCSVForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index ecf63b49f36..a2ff7085b9c 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -335,9 +335,12 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('protocol', 'group_id')), + ('Attributes', ('name', 'protocol', 'group_id')), ('Authentication', ('auth_type', 'auth_key')), ) + name = forms.CharField( + required=False + ) protocol = MultipleChoiceField( choices=FHRPGroupProtocolChoices, required=False diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 7248125856d..dea42065c07 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -527,7 +527,7 @@ class FHRPGroupForm(NetBoxModelForm): ) fieldsets = ( - ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), + ('FHRP Group', ('protocol', 'group_id', 'name', 'description', 'tags')), ('Authentication', ('auth_type', 'auth_key')), ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) ) @@ -535,7 +535,7 @@ class FHRPGroupForm(NetBoxModelForm): class Meta: model = FHRPGroup fields = ( - 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', + 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', ) def save(self, *args, **kwargs): diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index dc4982e269c..cfbfb036b1b 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -36,7 +36,7 @@ class FHRPGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( - 'pk', 'name', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', + 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'name', 'description', 'ip_addresses', 'member_count', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 0fefb01629e..1bbeb048784 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -552,6 +552,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase): 'group_id': 200, 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, 'auth_key': 'foobarbaz999', + 'name': 'foobar-999' 'description': 'New description', } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 5c41137866d..abb5a3cc30a 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -932,7 +932,7 @@ def setUpTestData(cls): fhrp_groups = ( FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foo123'), - FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='bar456'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='bar456', name='bar123'), FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), ) FHRPGroup.objects.bulk_create(fhrp_groups) @@ -956,6 +956,10 @@ def test_auth_key(self): params = {'auth_key': ['foo123', 'bar456']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_name(self): + params = {'name': ['bar123', ]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_related_ip(self): # Create some regular IPs to query for related IPs ipaddresses = ( diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 27520229a02..dc9b8914172 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -524,6 +524,7 @@ def setUpTestData(cls): 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, 'auth_key': 'abc123def456', 'description': 'Blah blah blah', + 'name': 'test123 name' 'tags': [t.pk for t in tags], } diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index b4911ce440d..89fc7083c1b 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -26,6 +26,10 @@
FHRP Group
Group ID {{ object.group_id }} + + Name + {{ object.name|placeholder }} + Description {{ object.description|placeholder }} diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html index 858d265ab28..02816b440cd 100644 --- a/netbox/templates/ipam/fhrpgroup_edit.html +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -8,6 +8,7 @@
FHRP Group
{% render_field form.protocol %} {% render_field form.group_id %} + {% render_field form.name %} {% render_field form.description %} {% render_field form.tags %}
From 3eeb31d5777a434a5e0250cd128a4fdb83039515 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 11:49:51 -0700 Subject: [PATCH 013/179] 9892 doh - fix tests --- netbox/ipam/tests/test_api.py | 2 +- netbox/ipam/tests/test_views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 1bbeb048784..ea644165029 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -552,7 +552,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase): 'group_id': 200, 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, 'auth_key': 'foobarbaz999', - 'name': 'foobar-999' + 'name': 'foobar-999', 'description': 'New description', } diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index dc9b8914172..5cc8fad247b 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -524,7 +524,7 @@ def setUpTestData(cls): 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, 'auth_key': 'abc123def456', 'description': 'Blah blah blah', - 'name': 'test123 name' + 'name': 'test123 name', 'tags': [t.pk for t in tags], } From 5ce805db2e2051b96e174014bbf6d258f65a8433 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 12:56:16 -0700 Subject: [PATCH 014/179] 9892 doh - fix tests --- netbox/ipam/forms/bulk_edit.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 7746b6625bc..67bcf83fb2d 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -321,6 +321,10 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Authentication key' ) + name = forms.CharField( + max_length=100, + required=False + ) description = forms.CharField( max_length=200, required=False From 10cb3c2c3d3550baea4d22ec7c998e1497856571 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 13:54:49 -0700 Subject: [PATCH 015/179] 9892 add name to str --- netbox/ipam/models/fhrp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index b022386c84c..fe61a7fecb4 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -59,7 +59,7 @@ class Meta: verbose_name = 'FHRP group' def __str__(self): - name = f'{self.get_protocol_display()}: {self.group_id}' + name = f'{self.name} {self.get_protocol_display()}: {self.group_id}' # Append the first assigned IP addresses (if any) to serve as an additional identifier if self.pk: From 8103ad3b9e441bedf09f22278604b9d966caddc3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 13:57:09 -0700 Subject: [PATCH 016/179] 9892 add name to str --- netbox/ipam/models/fhrp.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index fe61a7fecb4..88e6e19d916 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -59,7 +59,11 @@ class Meta: verbose_name = 'FHRP group' def __str__(self): - name = f'{self.name} {self.get_protocol_display()}: {self.group_id}' + name = '' + if self.name: + name = f'{self.name} ' + + name += f'{self.get_protocol_display()}: {self.group_id}' # Append the first assigned IP addresses (if any) to serve as an additional identifier if self.pk: From b134d2a7b0daa61aa20769390cfc9f797a440108 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 14:23:53 -0700 Subject: [PATCH 017/179] 9071 fix test --- netbox/extras/tests/test_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 299cab9efa0..733ae3a39b3 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -63,7 +63,7 @@ def test_menu_items(self): Check that plugin MenuItems and MenuButtons are registered. """ self.assertIn('Dummy plugin', registry['plugins']['menu_items']) - menu_items = registry['plugins']['menu_items']['Dummy plugin'] + menu_items = registry['plugins']['menu_items']['Dummy plugin']['items'] self.assertEqual(len(menu_items), 2) self.assertEqual(len(menu_items[0].buttons), 2) From 7deb9fde9e3822c83076b08896de62b8ed578f7a Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 26 Sep 2022 14:41:46 -0700 Subject: [PATCH 018/179] 9071 add documentation --- docs/plugins/development/navigation.md | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 52ae953a74e..b4a872ae2bd 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -32,6 +32,41 @@ A `PluginMenuItem` has the following attributes: | `permissions` | - | A list of permissions required to display this link | | `buttons` | - | An iterable of PluginMenuButton instances to include | +## Optional Header + +Plugin menus normally appear under the "Plugins" header. An optional menu_heading can be defined to make the plugin menu to appear as a top level menu header. An example is shown below: + +```python +from extras.plugins import PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +menu_heading = { + "title": "Animal Sound", + "icon": "mdi-puzzle" +} + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_animal_sounds:random_animal', + link_text='Random sound', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) + ), +) +``` + +The `menu_heading` has the following attributes: + +| Attribute | Required | Description | +|---------------|----------|------------------------------------------------------| +| `title` | Yes | The text that will show in the menu header | +| `icon` | Yes | The icon to use next to the headermdi | + +!!! tip + The icon names can be found at [Material Design Icons](https://materialdesignicons.com/) + ## Menu Buttons A `PluginMenuButton` has the following attributes: From 6016e1b15daade776be0d407c51ae53e707f7d6e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 09:55:35 -0400 Subject: [PATCH 019/179] Changelog & cleanup for #9892 --- docs/models/ipam/fhrpgroup.md | 4 ---- docs/release-notes/version-3.4.md | 9 +++++++++ netbox/ipam/tables/fhrp.py | 8 +++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md index ddb3010e266..de09fee298c 100644 --- a/docs/models/ipam/fhrpgroup.md +++ b/docs/models/ipam/fhrpgroup.md @@ -23,10 +23,6 @@ The group's numeric identifier. An optional name for the FHRP group. -### Description - -A brief description of the FHRP group. - ### Authentication Type The type of authentication employed by group nodes, if any. diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 39c44f38ebc..257ffd6254c 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -3,6 +3,10 @@ !!! warning "PostgreSQL 11 Required" NetBox v3.4 requires PostgreSQL 11 or later. +### Enhancements + +* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups + ### Plugins API * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin @@ -10,3 +14,8 @@ ### Other Changes * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 + +### REST API Changes + +* ipam.FHRPGroup + * Added optional `name` field diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index cfbfb036b1b..beffdd232f6 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -36,10 +36,12 @@ class FHRPGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'name', 'description', 'ip_addresses', 'member_count', - 'tags', 'created', 'last_updated', + 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'member_count', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'group_id', 'protocol', 'name', 'auth_type', 'description', 'ip_addresses', 'member_count', ) - default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') class FHRPGroupAssignmentTable(NetBoxTable): From ec6457bcd386dd08061203959fe0c27d79bb9ca7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 14 Sep 2022 09:16:25 -0400 Subject: [PATCH 020/179] Remove custom validate_unique() methods --- .../migrations/0162_unique_constraints.py | 81 +++++++++++++++++++ netbox/dcim/models/devices.py | 40 +++++---- netbox/dcim/models/sites.py | 60 +++----------- netbox/dcim/tests/test_models.py | 6 +- .../migrations/0033_unique_constraints.py | 25 ++++++ netbox/virtualization/models.py | 30 +++---- 6 files changed, 153 insertions(+), 89 deletions(-) create mode 100644 netbox/dcim/migrations/0162_unique_constraints.py create mode 100644 netbox/virtualization/migrations/0033_unique_constraints.py diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py new file mode 100644 index 00000000000..08c113f50e7 --- /dev/null +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -0,0 +1,81 @@ +# Generated by Django 4.1.1 on 2022-09-14 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0161_cabling_cleanup'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='location', + name='dcim_location_name', + ), + migrations.RemoveConstraint( + model_name='location', + name='dcim_location_slug', + ), + migrations.RemoveConstraint( + model_name='region', + name='dcim_region_name', + ), + migrations.RemoveConstraint( + model_name='region', + name='dcim_region_slug', + ), + migrations.RemoveConstraint( + model_name='sitegroup', + name='dcim_sitegroup_name', + ), + migrations.RemoveConstraint( + model_name='sitegroup', + name='dcim_sitegroup_slug', + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('name', 'site', 'tenant'), name='dcim_device_unique_name_site_tenant'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'site'), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('rack', 'position', 'face'), name='dcim_device_unique_rack_position_face'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('virtual_chassis', 'vc_position'), name='dcim_device_unique_virtual_chassis_vc_position'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'name'), name='dcim_location_name', violation_error_message='A location with this name already exists within the specified site.'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'slug'), name='dcim_location_slug', violation_error_message='A location with this slug already exists within the specified site.'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_region_name', violation_error_message='A top-level region with this name already exists.'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_region_slug', violation_error_message='A top-level region with this slug already exists.'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_sitegroup_name', violation_error_message='A top-level site group with this name already exists.'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_sitegroup_slug', violation_error_message='A top-level site group with this slug already exists.'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 7858960a1f7..eb21e532b6e 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -651,10 +651,25 @@ class Device(NetBoxModel, ConfigContextModel): class Meta: ordering = ('_name', 'pk') # Name may be null - unique_together = ( - ('site', 'tenant', 'name'), # See validate_unique below - ('rack', 'position', 'face'), - ('virtual_chassis', 'vc_position'), + constraints = ( + models.UniqueConstraint( + name='dcim_device_unique_name_site_tenant', + fields=('name', 'site', 'tenant') + ), + models.UniqueConstraint( + name='dcim_device_unique_name_site', + fields=('name', 'site'), + condition=Q(tenant__isnull=True), + violation_error_message="Device name must be unique per site." + ), + models.UniqueConstraint( + name='dcim_device_unique_rack_position_face', + fields=('rack', 'position', 'face') + ), + models.UniqueConstraint( + name='dcim_device_unique_virtual_chassis_vc_position', + fields=('virtual_chassis', 'vc_position') + ), ) def __str__(self): @@ -679,23 +694,6 @@ def get_prerequisite_models(cls): def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) - def validate_unique(self, exclude=None): - - # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary - # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation - # of the uniqueness constraint without manual intervention. - if self.name and hasattr(self, 'site') and self.tenant is None: - if Device.objects.exclude(pk=self.pk).filter( - name=self.name, - site=self.site, - tenant__isnull=True - ): - raise ValidationError({ - 'name': 'A device with this name already exists.' - }) - - super().validate_unique(exclude) - def clean(self): super().clean() diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index f5c8e6d9d76..90f8557410f 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -67,7 +67,8 @@ class Meta: models.UniqueConstraint( fields=('name',), name='dcim_region_name', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level region with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), @@ -76,24 +77,11 @@ class Meta: models.UniqueConstraint( fields=('slug',), name='dcim_region_slug', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level region with this slug already exists." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - regions = Region.objects.exclude(pk=self.pk) - if regions.filter(name=self.name, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A region with this name already exists.' - }) - if regions.filter(slug=self.slug, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A region with this slug already exists.' - }) - - super().validate_unique(exclude=exclude) - def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -153,7 +141,8 @@ class Meta: models.UniqueConstraint( fields=('name',), name='dcim_sitegroup_name', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level site group with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), @@ -162,24 +151,11 @@ class Meta: models.UniqueConstraint( fields=('slug',), name='dcim_sitegroup_slug', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A top-level site group with this slug already exists." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - site_groups = SiteGroup.objects.exclude(pk=self.pk) - if site_groups.filter(name=self.name, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A site group with this name already exists.' - }) - if site_groups.filter(slug=self.slug, parent__isnull=True).exists(): - raise ValidationError({ - 'name': 'A site group with this slug already exists.' - }) - - super().validate_unique(exclude=exclude) - def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -384,7 +360,8 @@ class Meta: models.UniqueConstraint( fields=('site', 'name'), name='dcim_location_name', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A location with this name already exists within the specified site." ), models.UniqueConstraint( fields=('site', 'parent', 'slug'), @@ -393,24 +370,11 @@ class Meta: models.UniqueConstraint( fields=('site', 'slug'), name='dcim_location_slug', - condition=Q(parent=None) + condition=Q(parent__isnull=True), + violation_error_message="A location with this slug already exists within the specified site." ), ) - def validate_unique(self, exclude=None): - if self.parent is None: - locations = Location.objects.exclude(pk=self.pk) - if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists(): - raise ValidationError({ - "name": f"A location with this name in site {self.site} already exists." - }) - if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists(): - raise ValidationError({ - "name": f"A location with this slug in site {self.site} already exists." - }) - - super().validate_unique(exclude=exclude) - @classmethod def get_prerequisite_models(cls): return [Site, ] diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 0e02b0de5ce..acde02ecd27 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -384,7 +384,7 @@ def test_multiple_unnamed_devices(self): site=self.site, device_type=self.device_type, device_role=self.device_role, - name='' + name=None ) device1.save() @@ -392,12 +392,12 @@ def test_multiple_unnamed_devices(self): site=device1.site, device_type=device1.device_type, device_role=device1.device_role, - name='' + name=None ) device2.full_clean() device2.save() - self.assertEqual(Device.objects.filter(name='').count(), 2) + self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2) def test_device_duplicate_names(self): diff --git a/netbox/virtualization/migrations/0033_unique_constraints.py b/netbox/virtualization/migrations/0033_unique_constraints.py new file mode 100644 index 00000000000..fe02881b00f --- /dev/null +++ b/netbox/virtualization/migrations/0033_unique_constraints.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.1 on 2022-09-14 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0032_virtualmachine_update_sites'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='virtualmachine', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='virtualmachine', + constraint=models.UniqueConstraint(fields=('name', 'cluster', 'tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), + ), + migrations.AddConstraint( + model_name='virtualmachine', + constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'cluster'), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per site.'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 4acbe6daf38..1b0a6ba06c1 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models +from django.db.models import Q from django.urls import reverse from dcim.models import BaseInterface, Device @@ -309,9 +310,18 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): class Meta: ordering = ('_name', 'pk') # Name may be non-unique - unique_together = [ - ['cluster', 'tenant', 'name'] - ] + constraints = ( + models.UniqueConstraint( + name='virtualization_virtualmachine_unique_name_cluster_tenant', + fields=('name', 'cluster', 'tenant') + ), + models.UniqueConstraint( + name='virtualization_virtualmachine_unique_name_cluster', + fields=('name', 'cluster'), + condition=Q(tenant__isnull=True), + violation_error_message="Virtual machine name must be unique per site." + ), + ) def __str__(self): return self.name @@ -323,20 +333,6 @@ def get_prerequisite_models(cls): def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) - def validate_unique(self, exclude=None): - - # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary - # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation - # of the uniqueness constraint without manual intervention. - if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter( - name=self.name, cluster=self.cluster, tenant__isnull=True - ): - raise ValidationError({ - 'name': 'A virtual machine with this name already exists in the assigned cluster.' - }) - - super().validate_unique(exclude) - def clean(self): super().clean() From f51415cf2c581b63bad0807b5d32f4d5c0231af9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 15:35:24 -0400 Subject: [PATCH 021/179] Replace unique_together with UniqueConstraints --- .../migrations/0039_unique_constraints.py | 39 +++ netbox/circuits/models/circuits.py | 14 +- netbox/circuits/models/providers.py | 3 +- .../migrations/0162_unique_constraints.py | 246 +++++++++++++++++- .../dcim/models/device_component_templates.py | 95 +++---- netbox/dcim/models/device_components.py | 62 ++--- netbox/dcim/models/devices.py | 21 +- netbox/dcim/models/power.py | 14 +- netbox/dcim/models/racks.py | 17 +- .../migrations/0078_unique_constraints.py | 27 ++ netbox/extras/models/models.py | 16 +- .../migrations/0062_unique_constraints.py | 43 +++ netbox/ipam/models/fhrp.py | 7 +- netbox/ipam/models/vlans.py | 28 +- .../migrations/0008_unique_constraints.py | 35 +++ netbox/tenancy/models/contacts.py | 21 +- .../migrations/0033_unique_constraints.py | 22 +- netbox/virtualization/models.py | 21 +- .../migrations/0006_unique_constraints.py | 27 ++ netbox/wireless/models.py | 14 +- 20 files changed, 630 insertions(+), 142 deletions(-) create mode 100644 netbox/circuits/migrations/0039_unique_constraints.py create mode 100644 netbox/extras/migrations/0078_unique_constraints.py create mode 100644 netbox/ipam/migrations/0062_unique_constraints.py create mode 100644 netbox/tenancy/migrations/0008_unique_constraints.py create mode 100644 netbox/wireless/migrations/0006_unique_constraints.py diff --git a/netbox/circuits/migrations/0039_unique_constraints.py b/netbox/circuits/migrations/0039_unique_constraints.py new file mode 100644 index 00000000000..1d5b6249906 --- /dev/null +++ b/netbox/circuits/migrations/0039_unique_constraints.py @@ -0,0 +1,39 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0038_cabling_cleanup'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='providernetwork', + name='circuits_providernetwork_provider_name', + ), + migrations.AlterUniqueTogether( + name='circuit', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='circuittermination', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='providernetwork', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='circuit', + constraint=models.UniqueConstraint(fields=('provider', 'cid'), name='circuits_circuit_unique_provider_cid'), + ), + migrations.AddConstraint( + model_name='circuittermination', + constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'), + ), + migrations.AddConstraint( + model_name='providernetwork', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index c08b5473a5f..ea74eeb40c8 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -132,7 +132,12 @@ class Circuit(NetBoxModel): class Meta: ordering = ['provider', 'cid'] - unique_together = ['provider', 'cid'] + constraints = ( + models.UniqueConstraint( + fields=('provider', 'cid'), + name='%(app_label)s_%(class)s_unique_provider_cid' + ), + ) def __str__(self): return self.cid @@ -208,7 +213,12 @@ class CircuitTermination( class Meta: ordering = ['circuit', 'term_side'] - unique_together = ['circuit', 'term_side'] + constraints = ( + models.UniqueConstraint( + fields=('circuit', 'term_side'), + name='%(app_label)s_%(class)s_unique_circuit_term_side' + ), + ) def __str__(self): return f'Termination {self.term_side}: {self.site or self.provider_network}' diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index e136e13ea36..2a1e01626a1 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -106,10 +106,9 @@ class Meta: constraints = ( models.UniqueConstraint( fields=('provider', 'name'), - name='circuits_providernetwork_provider_name' + name='%(app_label)s_%(class)s_unique_provider_name' ), ) - unique_together = ('provider', 'name') def __str__(self): return self.name diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py index 08c113f50e7..a2f471632b5 100644 --- a/netbox/dcim/migrations/0162_unique_constraints.py +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -1,5 +1,3 @@ -# Generated by Django 4.1.1 on 2022-09-14 20:57 - from django.db import migrations, models @@ -34,10 +32,134 @@ class Migration(migrations.Migration): model_name='sitegroup', name='dcim_sitegroup_slug', ), + migrations.AlterUniqueTogether( + name='consoleport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleserverport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together=set(), + ), migrations.AlterUniqueTogether( name='device', unique_together=set(), ), + migrations.AlterUniqueTogether( + name='devicebay', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='devicebaytemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='devicetype', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='frontport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='interface', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='inventoryitem', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='inventoryitemtemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='modulebay', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='modulebaytemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='moduletype', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerfeed', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='poweroutlet', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerpanel', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rearport', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='consoleport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='consoleporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='consoleporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='consoleserverport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleserverport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='consoleserverporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleserverporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='consoleserverporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleserverporttemplate_unique_module_type_name'), + ), migrations.AddConstraint( model_name='device', constraint=models.UniqueConstraint(fields=('name', 'site', 'tenant'), name='dcim_device_unique_name_site_tenant'), @@ -54,6 +176,62 @@ class Migration(migrations.Migration): model_name='device', constraint=models.UniqueConstraint(fields=('virtual_chassis', 'vc_position'), name='dcim_device_unique_virtual_chassis_vc_position'), ), + migrations.AddConstraint( + model_name='devicebay', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_devicebay_unique_device_name'), + ), + migrations.AddConstraint( + model_name='devicebaytemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_devicebaytemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='devicetype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_devicetype_unique_manufacturer_model'), + ), + migrations.AddConstraint( + model_name='devicetype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'slug'), name='dcim_devicetype_unique_manufacturer_slug'), + ), + migrations.AddConstraint( + model_name='frontport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_frontport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='frontport', + constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontport_unique_rear_port_position'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_frontporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_frontporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='frontporttemplate', + constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontporttemplate_unique_rear_port_position'), + ), + migrations.AddConstraint( + model_name='interface', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_interface_unique_device_name'), + ), + migrations.AddConstraint( + model_name='interfacetemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_interfacetemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='interfacetemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_interfacetemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='inventoryitem', + constraint=models.UniqueConstraint(fields=('device', 'parent', 'name'), name='dcim_inventoryitem_unique_device_parent_name'), + ), + migrations.AddConstraint( + model_name='inventoryitemtemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'parent', 'name'), name='dcim_inventoryitemtemplate_unique_device_type_parent_name'), + ), migrations.AddConstraint( model_name='location', constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'name'), name='dcim_location_name', violation_error_message='A location with this name already exists within the specified site.'), @@ -62,6 +240,70 @@ class Migration(migrations.Migration): model_name='location', constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'slug'), name='dcim_location_slug', violation_error_message='A location with this slug already exists within the specified site.'), ), + migrations.AddConstraint( + model_name='modulebay', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_modulebay_unique_device_name'), + ), + migrations.AddConstraint( + model_name='modulebaytemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_modulebaytemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='moduletype', + constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_moduletype_unique_manufacturer_model'), + ), + migrations.AddConstraint( + model_name='powerfeed', + constraint=models.UniqueConstraint(fields=('power_panel', 'name'), name='dcim_powerfeed_unique_power_panel_name'), + ), + migrations.AddConstraint( + model_name='poweroutlet', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_poweroutlet_unique_device_name'), + ), + migrations.AddConstraint( + model_name='poweroutlettemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_poweroutlettemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='poweroutlettemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_poweroutlettemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='powerpanel', + constraint=models.UniqueConstraint(fields=('site', 'name'), name='dcim_powerpanel_unique_site_name'), + ), + migrations.AddConstraint( + model_name='powerport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_powerport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='powerporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_powerporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='powerporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_powerporttemplate_unique_module_type_name'), + ), + migrations.AddConstraint( + model_name='rack', + constraint=models.UniqueConstraint(fields=('location', 'name'), name='dcim_rack_unique_location_name'), + ), + migrations.AddConstraint( + model_name='rack', + constraint=models.UniqueConstraint(fields=('location', 'facility_id'), name='dcim_rack_unique_location_facility_id'), + ), + migrations.AddConstraint( + model_name='rearport', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_rearport_unique_device_name'), + ), + migrations.AddConstraint( + model_name='rearporttemplate', + constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_rearporttemplate_unique_device_type_name'), + ), + migrations.AddConstraint( + model_name='rearporttemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_rearporttemplate_unique_module_type_name'), + ), migrations.AddConstraint( model_name='region', constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_region_name', violation_error_message='A top-level region with this name already exists.'), diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b7079d37521..15389a2c030 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -61,6 +61,13 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): class Meta: abstract = True + ordering = ('device_type', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + ) def __str__(self): if self.label: @@ -100,6 +107,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True + ordering = ('device_type', 'module_type', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + models.UniqueConstraint( + fields=('module_type', 'name'), + name='%(app_label)s_%(class)s_unique_module_type_name' + ), + ) def to_objectchange(self, action): objectchange = super().to_objectchange(action) @@ -145,13 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel): component_model = ConsolePort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -181,13 +192,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): component_model = ConsoleServerPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -229,13 +233,6 @@ class PowerPortTemplate(ModularComponentTemplateModel): component_model = PowerPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -291,13 +288,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel): component_model = PowerOutlet - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def clean(self): super().clean() @@ -372,13 +362,6 @@ class InterfaceTemplate(ModularComponentTemplateModel): component_model = Interface - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -428,12 +411,20 @@ class FrontPortTemplate(ModularComponentTemplateModel): component_model = FrontPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ('rear_port', 'rear_port_position'), + class Meta(ModularComponentTemplateModel.Meta): + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_name' + ), + models.UniqueConstraint( + fields=('module_type', 'name'), + name='%(app_label)s_%(class)s_unique_module_type_name' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), ) def clean(self): @@ -507,13 +498,6 @@ class RearPortTemplate(ModularComponentTemplateModel): component_model = RearPort - class Meta: - ordering = ('device_type', 'module_type', '_name') - unique_together = ( - ('device_type', 'name'), - ('module_type', 'name'), - ) - def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -547,10 +531,6 @@ class ModuleBayTemplate(ComponentTemplateModel): component_model = ModuleBay - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') - def instantiate(self, device): return self.component_model( device=device, @@ -574,10 +554,6 @@ class DeviceBayTemplate(ComponentTemplateModel): """ component_model = DeviceBay - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') - def instantiate(self, device): return self.component_model( device=device, @@ -653,7 +629,12 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): class Meta: ordering = ('device_type__id', 'parent__id', '_name') - unique_together = ('device_type', 'parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('device_type', 'parent', 'name'), + name='%(app_label)s_%(class)s_unique_device_type_parent_name' + ), + ) def instantiate(self, **kwargs): parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index c521ee0956f..59d63ef7ba8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -69,6 +69,13 @@ class ComponentModel(NetBoxModel): class Meta: abstract = True + ordering = ('device', '_name') + constraints = ( + models.UniqueConstraint( + fields=('device', 'name'), + name='%(app_label)s_%(class)s_unique_device_name' + ), + ) def __str__(self): if self.label: @@ -99,7 +106,7 @@ class ModularComponentModel(ComponentModel): object_id_field='component_id' ) - class Meta: + class Meta(ComponentModel.Meta): abstract = True @@ -265,10 +272,6 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'type', 'speed') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) @@ -292,10 +295,6 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'type', 'speed') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:consoleserverport', kwargs={'pk': self.pk}) @@ -329,10 +328,6 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:powerport', kwargs={'pk': self.pk}) @@ -443,10 +438,6 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:poweroutlet', kwargs={'pk': self.pk}) @@ -677,9 +668,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf', ) - class Meta: + class Meta(ModularComponentModel.Meta): ordering = ('device', CollateAsChar('_name')) - unique_together = ('device', 'name') def get_absolute_url(self): return reverse('dcim:interface', kwargs={'pk': self.pk}) @@ -895,11 +885,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel): clone_fields = ('device', 'type', 'color') - class Meta: - ordering = ('device', '_name') - unique_together = ( - ('device', 'name'), - ('rear_port', 'rear_port_position'), + class Meta(ModularComponentModel.Meta): + constraints = ( + models.UniqueConstraint( + fields=('device', 'name'), + name='%(app_label)s_%(class)s_unique_device_name' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), ) def get_absolute_url(self): @@ -944,10 +939,6 @@ class RearPort(ModularComponentModel, CabledObjectModel): ) clone_fields = ('device', 'type', 'color', 'positions') - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:rearport', kwargs={'pk': self.pk}) @@ -980,10 +971,6 @@ class ModuleBay(ComponentModel): clone_fields = ('device',) - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) @@ -1002,10 +989,6 @@ class DeviceBay(ComponentModel): clone_fields = ('device',) - class Meta: - ordering = ('device', '_name') - unique_together = ('device', 'name') - def get_absolute_url(self): return reverse('dcim:devicebay', kwargs={'pk': self.pk}) @@ -1141,7 +1124,12 @@ class InventoryItem(MPTTModel, ComponentModel): class Meta: ordering = ('device__id', 'parent__id', '_name') - unique_together = ('device', 'parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('device', 'parent', 'name'), + name='%(app_label)s_%(class)s_unique_device_parent_name' + ), + ) def get_absolute_url(self): return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index eb21e532b6e..491846c3931 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -143,10 +143,16 @@ class DeviceType(NetBoxModel): class Meta: ordering = ['manufacturer', 'model'] - unique_together = [ - ['manufacturer', 'model'], - ['manufacturer', 'slug'], - ] + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), + models.UniqueConstraint( + fields=('manufacturer', 'slug'), + name='%(app_label)s_%(class)s_unique_manufacturer_slug' + ), + ) def __str__(self): return self.model @@ -341,8 +347,11 @@ class ModuleType(NetBoxModel): class Meta: ordering = ('manufacturer', 'model') - unique_together = ( - ('manufacturer', 'model'), + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), ) def __str__(self): diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 83eead67fe8..39f0f37ef5a 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -50,7 +50,12 @@ class PowerPanel(NetBoxModel): class Meta: ordering = ['site', 'name'] - unique_together = ['site', 'name'] + constraints = ( + models.UniqueConstraint( + fields=('site', 'name'), + name='%(app_label)s_%(class)s_unique_site_name' + ), + ) def __str__(self): return self.name @@ -138,7 +143,12 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): class Meta: ordering = ['power_panel', 'name'] - unique_together = ['power_panel', 'name'] + constraints = ( + models.UniqueConstraint( + fields=('power_panel', 'name'), + name='%(app_label)s_%(class)s_unique_power_panel_name' + ), + ) def __str__(self): return self.name diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 20027675ab1..10550e9063a 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -3,12 +3,11 @@ from django.apps import apps from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Count, Sum +from django.db.models import Count from django.urls import reverse from dcim.choices import * @@ -18,7 +17,7 @@ from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string, drange -from .device_components import PowerOutlet, PowerPort +from .device_components import PowerPort from .devices import Device from .power import PowerFeed @@ -191,10 +190,16 @@ class Rack(NetBoxModel): class Meta: ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique - unique_together = ( + constraints = ( # Name and facility_id must be unique *only* within a Location - ('location', 'name'), - ('location', 'facility_id'), + models.UniqueConstraint( + fields=('location', 'name'), + name='%(app_label)s_%(class)s_unique_location_name' + ), + models.UniqueConstraint( + fields=('location', 'facility_id'), + name='%(app_label)s_%(class)s_unique_location_facility_id' + ), ) def __str__(self): diff --git a/netbox/extras/migrations/0078_unique_constraints.py b/netbox/extras/migrations/0078_unique_constraints.py new file mode 100644 index 00000000000..4a56831a7f1 --- /dev/null +++ b/netbox/extras/migrations/0078_unique_constraints.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0077_customlink_extend_text_and_url'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='exporttemplate', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='webhook', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='exporttemplate', + constraint=models.UniqueConstraint(fields=('content_type', 'name'), name='extras_exporttemplate_unique_content_type_name'), + ), + migrations.AddConstraint( + model_name='webhook', + constraint=models.UniqueConstraint(fields=('payload_url', 'type_create', 'type_update', 'type_delete'), name='extras_webhook_unique_payload_url_types'), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 0df34c146b1..266953f61f6 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -131,7 +131,12 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('name',) - unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) + constraints = ( + models.UniqueConstraint( + fields=('payload_url', 'type_create', 'type_update', 'type_delete'), + name='%(app_label)s_%(class)s_unique_payload_url_types' + ), + ) def __str__(self): return self.name @@ -297,9 +302,12 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ['content_type', 'name'] - unique_together = [ - ['content_type', 'name'] - ] + constraints = ( + models.UniqueConstraint( + fields=('content_type', 'name'), + name='%(app_label)s_%(class)s_unique_content_type_name' + ), + ) def __str__(self): return f"{self.content_type}: {self.name}" diff --git a/netbox/ipam/migrations/0062_unique_constraints.py b/netbox/ipam/migrations/0062_unique_constraints.py new file mode 100644 index 00000000000..47c1a121464 --- /dev/null +++ b/netbox/ipam/migrations/0062_unique_constraints.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0061_fhrpgroup_name'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='fhrpgroupassignment', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='vlan', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='vlangroup', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='fhrpgroupassignment', + constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id', 'group'), name='ipam_fhrpgroupassignment_unique_interface_group'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('group', 'vid'), name='ipam_vlan_unique_group_vid'), + ), + migrations.AddConstraint( + model_name='vlan', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='ipam_vlan_unique_group_name'), + ), + migrations.AddConstraint( + model_name='vlangroup', + constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'name'), name='ipam_vlangroup_unique_scope_name'), + ), + migrations.AddConstraint( + model_name='vlangroup', + constraint=models.UniqueConstraint(fields=('scope_type', 'scope_id', 'slug'), name='ipam_vlangroup_unique_scope_slug'), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 88e6e19d916..633affa418a 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -102,7 +102,12 @@ class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('-priority', 'pk') - unique_together = ('interface_type', 'interface_id', 'group') + constraints = ( + models.UniqueConstraint( + fields=('interface_type', 'interface_id', 'group'), + name='%(app_label)s_%(class)s_unique_interface_group' + ), + ) verbose_name = 'FHRP group assignment' def __str__(self): diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index f0e062721a3..c8c401e1cf3 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -70,10 +70,16 @@ class VLANGroup(OrganizationalModel): class Meta: ordering = ('name', 'pk') # Name may be non-unique - unique_together = [ - ['scope_type', 'scope_id', 'name'], - ['scope_type', 'scope_id', 'slug'], - ] + constraints = ( + models.UniqueConstraint( + fields=('scope_type', 'scope_id', 'name'), + name='%(app_label)s_%(class)s_unique_scope_name' + ), + models.UniqueConstraint( + fields=('scope_type', 'scope_id', 'slug'), + name='%(app_label)s_%(class)s_unique_scope_slug' + ), + ) verbose_name = 'VLAN group' verbose_name_plural = 'VLAN groups' @@ -189,10 +195,16 @@ class VLAN(NetBoxModel): class Meta: ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique - unique_together = [ - ['group', 'vid'], - ['group', 'name'], - ] + constraints = ( + models.UniqueConstraint( + fields=('group', 'vid'), + name='%(app_label)s_%(class)s_unique_group_vid' + ), + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), + ) verbose_name = 'VLAN' verbose_name_plural = 'VLANs' diff --git a/netbox/tenancy/migrations/0008_unique_constraints.py b/netbox/tenancy/migrations/0008_unique_constraints.py new file mode 100644 index 00000000000..092878524e2 --- /dev/null +++ b/netbox/tenancy/migrations/0008_unique_constraints.py @@ -0,0 +1,35 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0007_contact_link'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='contact', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='contactassignment', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='contactgroup', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='contact', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='tenancy_contact_unique_group_name'), + ), + migrations.AddConstraint( + model_name='contactassignment', + constraint=models.UniqueConstraint(fields=('content_type', 'object_id', 'contact', 'role'), name='tenancy_contactassignment_unique_object_contact_role'), + ), + migrations.AddConstraint( + model_name='contactgroup', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='tenancy_contactgroup_unique_parent_name'), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 41881f853ea..79c0a2db396 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -41,8 +41,11 @@ class ContactGroup(NestedGroupModel): class Meta: ordering = ['name'] - unique_together = ( - ('parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='%(app_label)s_%(class)s_unique_parent_name' + ), ) def get_absolute_url(self): @@ -118,8 +121,11 @@ class Contact(NetBoxModel): class Meta: ordering = ['name'] - unique_together = ( - ('group', 'name') + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), ) def __str__(self): @@ -159,7 +165,12 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel): class Meta: ordering = ('priority', 'contact') - unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority') + constraints = ( + models.UniqueConstraint( + fields=('content_type', 'object_id', 'contact', 'role'), + name='%(app_label)s_%(class)s_unique_object_contact_role' + ), + ) def __str__(self): if self.priority: diff --git a/netbox/virtualization/migrations/0033_unique_constraints.py b/netbox/virtualization/migrations/0033_unique_constraints.py index fe02881b00f..4667dcbd3d8 100644 --- a/netbox/virtualization/migrations/0033_unique_constraints.py +++ b/netbox/virtualization/migrations/0033_unique_constraints.py @@ -1,5 +1,3 @@ -# Generated by Django 4.1.1 on 2022-09-14 20:57 - from django.db import migrations, models @@ -10,10 +8,26 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterUniqueTogether( + name='cluster', + unique_together=set(), + ), migrations.AlterUniqueTogether( name='virtualmachine', unique_together=set(), ), + migrations.AlterUniqueTogether( + name='vminterface', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='cluster', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='virtualization_cluster_unique_group_name'), + ), + migrations.AddConstraint( + model_name='cluster', + constraint=models.UniqueConstraint(fields=('site', 'name'), name='virtualization_cluster_unique_site_name'), + ), migrations.AddConstraint( model_name='virtualmachine', constraint=models.UniqueConstraint(fields=('name', 'cluster', 'tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), @@ -22,4 +36,8 @@ class Migration(migrations.Migration): model_name='virtualmachine', constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'cluster'), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per site.'), ), + migrations.AddConstraint( + model_name='vminterface', + constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_vminterface_unique_virtual_machine_name'), + ), ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 1b0a6ba06c1..b0e73218803 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -160,9 +160,15 @@ class Cluster(NetBoxModel): class Meta: ordering = ['name'] - unique_together = ( - ('group', 'name'), - ('site', 'name'), + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_unique_group_name' + ), + models.UniqueConstraint( + fields=('site', 'name'), + name='%(app_label)s_%(class)s_unique_site_name' + ), ) def __str__(self): @@ -461,9 +467,14 @@ class VMInterface(NetBoxModel, BaseInterface): ) class Meta: - verbose_name = 'interface' ordering = ('virtual_machine', CollateAsChar('_name')) - unique_together = ('virtual_machine', 'name') + constraints = ( + models.UniqueConstraint( + fields=('virtual_machine', 'name'), + name='%(app_label)s_%(class)s_unique_virtual_machine_name' + ), + ) + verbose_name = 'interface' def __str__(self): return self.name diff --git a/netbox/wireless/migrations/0006_unique_constraints.py b/netbox/wireless/migrations/0006_unique_constraints.py new file mode 100644 index 00000000000..f638ae1abba --- /dev/null +++ b/netbox/wireless/migrations/0006_unique_constraints.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0005_wirelesslink_interface_types'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='wirelesslangroup', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='wirelesslink', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='wirelesslangroup', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='wireless_wirelesslangroup_unique_parent_name'), + ), + migrations.AddConstraint( + model_name='wirelesslink', + constraint=models.UniqueConstraint(fields=('interface_a', 'interface_b'), name='wireless_wirelesslink_unique_interfaces'), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index c383ad64247..29fe33f4bce 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -69,8 +69,11 @@ class WirelessLANGroup(NestedGroupModel): class Meta: ordering = ('name', 'pk') - unique_together = ( - ('parent', 'name') + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='%(app_label)s_%(class)s_unique_parent_name' + ), ) verbose_name = 'Wireless LAN Group' @@ -195,7 +198,12 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel): class Meta: ordering = ['pk'] - unique_together = ('interface_a', 'interface_b') + constraints = ( + models.UniqueConstraint( + fields=('interface_a', 'interface_b'), + name='%(app_label)s_%(class)s_unique_interfaces' + ), + ) def __str__(self): return f'#{self.pk}' From 7ff2cb75a8fe40a464c180547a495c887a3fdab1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 15:44:38 -0400 Subject: [PATCH 022/179] Use templated app & model names for all unique constraints --- .../migrations/0162_unique_constraints.py | 8 +++++++ netbox/dcim/models/cables.py | 2 +- netbox/dcim/models/devices.py | 14 +++++------ netbox/dcim/models/sites.py | 24 +++++++++---------- netbox/virtualization/models.py | 6 ++--- 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py index a2f471632b5..5dac7039c4f 100644 --- a/netbox/dcim/migrations/0162_unique_constraints.py +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -8,6 +8,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RemoveConstraint( + model_name='cabletermination', + name='dcim_cable_termination_unique_termination', + ), migrations.RemoveConstraint( model_name='location', name='dcim_location_name', @@ -136,6 +140,10 @@ class Migration(migrations.Migration): name='rearporttemplate', unique_together=set(), ), + migrations.AddConstraint( + model_name='cabletermination', + constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cabletermination_unique_termination'), + ), migrations.AddConstraint( model_name='consoleport', constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleport_unique_device_name'), diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e05eb6d51a3..fad3e8bd6d6 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -269,7 +269,7 @@ class Meta: constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), - name='dcim_cable_termination_unique_termination' + name='%(app_label)s_%(class)s_unique_termination' ), ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 491846c3931..79cc8c86b76 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -662,22 +662,22 @@ class Meta: ordering = ('_name', 'pk') # Name may be null constraints = ( models.UniqueConstraint( - name='dcim_device_unique_name_site_tenant', - fields=('name', 'site', 'tenant') + fields=('name', 'site', 'tenant'), + name='%(app_label)s_%(class)s_unique_name_site_tenant' ), models.UniqueConstraint( - name='dcim_device_unique_name_site', fields=('name', 'site'), + name='%(app_label)s_%(class)s_unique_name_site', condition=Q(tenant__isnull=True), violation_error_message="Device name must be unique per site." ), models.UniqueConstraint( - name='dcim_device_unique_rack_position_face', - fields=('rack', 'position', 'face') + fields=('rack', 'position', 'face'), + name='%(app_label)s_%(class)s_unique_rack_position_face' ), models.UniqueConstraint( - name='dcim_device_unique_virtual_chassis_vc_position', - fields=('virtual_chassis', 'vc_position') + fields=('virtual_chassis', 'vc_position'), + name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position' ), ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 90f8557410f..9ddadace2b9 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -62,21 +62,21 @@ class Meta: constraints = ( models.UniqueConstraint( fields=('parent', 'name'), - name='dcim_region_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('name',), - name='dcim_region_name', + name='%(app_label)s_%(class)s_name', condition=Q(parent__isnull=True), violation_error_message="A top-level region with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), - name='dcim_region_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('slug',), - name='dcim_region_slug', + name='%(app_label)s_%(class)s_slug', condition=Q(parent__isnull=True), violation_error_message="A top-level region with this slug already exists." ), @@ -136,21 +136,21 @@ class Meta: constraints = ( models.UniqueConstraint( fields=('parent', 'name'), - name='dcim_sitegroup_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('name',), - name='dcim_sitegroup_name', + name='%(app_label)s_%(class)s_name', condition=Q(parent__isnull=True), violation_error_message="A top-level site group with this name already exists." ), models.UniqueConstraint( fields=('parent', 'slug'), - name='dcim_sitegroup_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('slug',), - name='dcim_sitegroup_slug', + name='%(app_label)s_%(class)s_slug', condition=Q(parent__isnull=True), violation_error_message="A top-level site group with this slug already exists." ), @@ -355,21 +355,21 @@ class Meta: constraints = ( models.UniqueConstraint( fields=('site', 'parent', 'name'), - name='dcim_location_parent_name' + name='%(app_label)s_%(class)s_parent_name' ), models.UniqueConstraint( fields=('site', 'name'), - name='dcim_location_name', + name='%(app_label)s_%(class)s_name', condition=Q(parent__isnull=True), violation_error_message="A location with this name already exists within the specified site." ), models.UniqueConstraint( fields=('site', 'parent', 'slug'), - name='dcim_location_parent_slug' + name='%(app_label)s_%(class)s_parent_slug' ), models.UniqueConstraint( fields=('site', 'slug'), - name='dcim_location_slug', + name='%(app_label)s_%(class)s_slug', condition=Q(parent__isnull=True), violation_error_message="A location with this slug already exists within the specified site." ), diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b0e73218803..5a1c361c2cb 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -318,12 +318,12 @@ class Meta: ordering = ('_name', 'pk') # Name may be non-unique constraints = ( models.UniqueConstraint( - name='virtualization_virtualmachine_unique_name_cluster_tenant', - fields=('name', 'cluster', 'tenant') + fields=('name', 'cluster', 'tenant'), + name='%(app_label)s_%(class)s_unique_name_cluster_tenant' ), models.UniqueConstraint( - name='virtualization_virtualmachine_unique_name_cluster', fields=('name', 'cluster'), + name='%(app_label)s_%(class)s_unique_name_cluster', condition=Q(tenant__isnull=True), violation_error_message="Virtual machine name must be unique per site." ), From 1d4f828b93550a09b4842c7a985f5394df6ef2a9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 16:19:39 -0400 Subject: [PATCH 023/179] Device/VM unique constraints ignore case for name field --- .../migrations/0162_unique_constraints.py | 5 ++-- netbox/dcim/models/devices.py | 5 ++-- netbox/dcim/tests/test_models.py | 21 +++++++++++++++ .../migrations/0033_unique_constraints.py | 5 ++-- netbox/virtualization/models.py | 7 ++--- netbox/virtualization/tests/test_models.py | 26 ++++++++++++++++--- 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py index 5dac7039c4f..d52dbb6c908 100644 --- a/netbox/dcim/migrations/0162_unique_constraints.py +++ b/netbox/dcim/migrations/0162_unique_constraints.py @@ -1,4 +1,5 @@ from django.db import migrations, models +import django.db.models.functions.text class Migration(migrations.Migration): @@ -170,11 +171,11 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='device', - constraint=models.UniqueConstraint(fields=('name', 'site', 'tenant'), name='dcim_device_unique_name_site_tenant'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), models.F('tenant'), name='dcim_device_unique_name_site_tenant'), ), migrations.AddConstraint( model_name='device', - constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'site'), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), condition=models.Q(('tenant__isnull', True)), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'), ), migrations.AddConstraint( model_name='device', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 79cc8c86b76..d0d9001adb5 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -8,6 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F, ProtectedError +from django.db.models.functions import Lower from django.urls import reverse from django.utils.safestring import mark_safe @@ -662,11 +663,11 @@ class Meta: ordering = ('_name', 'pk') # Name may be null constraints = ( models.UniqueConstraint( - fields=('name', 'site', 'tenant'), + Lower('name'), 'site', 'tenant', name='%(app_label)s_%(class)s_unique_name_site_tenant' ), models.UniqueConstraint( - fields=('name', 'site'), + Lower('name'), 'site', name='%(app_label)s_%(class)s_unique_name_site', condition=Q(tenant__isnull=True), violation_error_message="Device name must be unique per site." diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index acde02ecd27..460a5e25269 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -399,6 +399,27 @@ def test_multiple_unnamed_devices(self): self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2) + def test_device_name_case_sensitivity(self): + + device1 = Device( + site=self.site, + device_type=self.device_type, + device_role=self.device_role, + name='device 1' + ) + device1.save() + + device2 = Device( + site=device1.site, + device_type=device1.device_type, + device_role=device1.device_role, + name='DEVICE 1' + ) + + # Uniqueness validation for name should ignore case + with self.assertRaises(ValidationError): + device2.full_clean() + def test_device_duplicate_names(self): device1 = Device( diff --git a/netbox/virtualization/migrations/0033_unique_constraints.py b/netbox/virtualization/migrations/0033_unique_constraints.py index 4667dcbd3d8..0624d36071f 100644 --- a/netbox/virtualization/migrations/0033_unique_constraints.py +++ b/netbox/virtualization/migrations/0033_unique_constraints.py @@ -1,4 +1,5 @@ from django.db import migrations, models +import django.db.models.functions.text class Migration(migrations.Migration): @@ -30,11 +31,11 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='virtualmachine', - constraint=models.UniqueConstraint(fields=('name', 'cluster', 'tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), models.F('tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), ), migrations.AddConstraint( model_name='virtualmachine', - constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'cluster'), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per site.'), + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), condition=models.Q(('tenant__isnull', True)), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per cluster.'), ), migrations.AddConstraint( model_name='vminterface', diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 5a1c361c2cb..37fcd68ae93 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -3,6 +3,7 @@ from django.core.validators import MinValueValidator from django.db import models from django.db.models import Q +from django.db.models.functions import Lower from django.urls import reverse from dcim.models import BaseInterface, Device @@ -318,14 +319,14 @@ class Meta: ordering = ('_name', 'pk') # Name may be non-unique constraints = ( models.UniqueConstraint( - fields=('name', 'cluster', 'tenant'), + Lower('name'), 'cluster', 'tenant', name='%(app_label)s_%(class)s_unique_name_cluster_tenant' ), models.UniqueConstraint( - fields=('name', 'cluster'), + Lower('name'), 'cluster', name='%(app_label)s_%(class)s_unique_name_cluster', condition=Q(tenant__isnull=True), - violation_error_message="Virtual machine name must be unique per site." + violation_error_message="Virtual machine name must be unique per cluster." ), ) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index df5816efa6d..bf0571d3dbe 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -8,12 +8,14 @@ class VirtualMachineTestCase(TestCase): - def test_vm_duplicate_name_per_cluster(self): + @classmethod + def setUpTestData(cls): cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') - cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) + Cluster.objects.create(name='Cluster 1', type=cluster_type) + def test_vm_duplicate_name_per_cluster(self): vm1 = VirtualMachine( - cluster=cluster, + cluster=Cluster.objects.first(), name='Test VM 1' ) vm1.save() @@ -43,7 +45,7 @@ def test_vm_duplicate_name_per_cluster(self): vm2.save() def test_vm_mismatched_site_cluster(self): - cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster_type = ClusterType.objects.first() sites = ( Site(name='Site 1', slug='site-1'), @@ -71,3 +73,19 @@ def test_vm_mismatched_site_cluster(self): # VM with cluster site but no direct site should fail with self.assertRaises(ValidationError): VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() + + def test_vm_name_case_sensitivity(self): + vm1 = VirtualMachine( + cluster=Cluster.objects.first(), + name='virtual machine 1' + ) + vm1.save() + + vm2 = VirtualMachine( + cluster=vm1.cluster, + name='VIRTUAL MACHINE 1' + ) + + # Uniqueness validation for name should ignore case + with self.assertRaises(ValidationError): + vm2.full_clean() From e977333177798cdf09a0ee4d8285908fb596d5e9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 16:48:39 -0400 Subject: [PATCH 024/179] Update device/VM name filters to be case-insensitive --- netbox/dcim/filtersets.py | 5 ++++- netbox/dcim/tests/test_filtersets.py | 3 +++ netbox/virtualization/filtersets.py | 7 +++++-- netbox/virtualization/tests/test_filtersets.py | 3 +++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0a4439173fa..3a66e6c3063 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -887,6 +887,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter to_field_name='slug', label='Device model (slug)', ) + name = MultiValueCharFilter( + lookup_expr='iexact' + ) status = django_filters.MultipleChoiceFilter( choices=DeviceStatusChoices, null_value=None @@ -950,7 +953,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter class Meta: model = Device - fields = ['id', 'name', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] + fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index feef4e90c7e..7a745721b0c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1611,6 +1611,9 @@ def setUpTestData(cls): def test_name(self): params = {'name': ['Device 1', 'Device 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # Test case insensitivity + params = {'name': ['DEVICE 1', 'DEVICE 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asset_tag(self): params = {'asset_tag': ['1001', '1002']} diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 00d3e231323..1b9c5bc7856 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -6,7 +6,7 @@ from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet -from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -196,6 +196,9 @@ class VirtualMachineFilterSet( to_field_name='slug', label='Site (slug)', ) + name = MultiValueCharFilter( + lookup_expr='iexact' + ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceRole.objects.all(), label='Role (ID)', @@ -227,7 +230,7 @@ class VirtualMachineFilterSet( class Meta: model = VirtualMachine - fields = ['id', 'name', 'cluster', 'vcpus', 'memory', 'disk'] + fields = ['id', 'cluster', 'vcpus', 'memory', 'disk'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d3ff128875b..d474af21a49 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -299,6 +299,9 @@ def setUpTestData(cls): def test_name(self): params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # Test case insensitivity + params = {'name': ['VIRTUAL MACHINE 1', 'VIRTUAL MACHINE 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_vcpus(self): params = {'vcpus': [1, 2]} From ad6a7086c42779dcc91bde801126caac1a4afa1a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Sep 2022 16:52:14 -0400 Subject: [PATCH 025/179] Changelog for #9249 --- docs/release-notes/version-3.4.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 257ffd6254c..98a576c70da 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -3,8 +3,13 @@ !!! warning "PostgreSQL 11 Required" NetBox v3.4 requires PostgreSQL 11 or later. +### Breaking Changes + +* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. + ### Enhancements +* [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups ### Plugins API From 20e3fdc7828946e0119a3f373bb5fbe6adea176c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 28 Sep 2022 12:22:19 -0700 Subject: [PATCH 026/179] #9045 #9046 - remove legacy fields from Provider (#10377) * #9045 - remove legacy fields from Provider * Add safegaurd for legacy data to migration * 9045 remove fields from forms and tables * Update unrelated tests to use ASN model instead of Provider * Fix migrations collision Co-authored-by: jeremystretch --- netbox/circuits/api/serializers.py | 2 +- netbox/circuits/filtersets.py | 4 +- netbox/circuits/forms/bulk_edit.py | 22 +------ netbox/circuits/forms/bulk_import.py | 2 +- netbox/circuits/forms/models.py | 18 +----- .../0040_provider_remove_deprecated_fields.py | 59 +++++++++++++++++++ netbox/circuits/models/providers.py | 20 +------ netbox/circuits/tables/providers.py | 4 +- netbox/circuits/tests/test_api.py | 2 +- netbox/circuits/tests/test_filtersets.py | 14 ++--- netbox/circuits/tests/test_views.py | 18 ++---- netbox/extras/tests/test_customvalidator.py | 20 ++++--- netbox/templates/circuits/provider.html | 29 --------- netbox/utilities/tests/test_filters.py | 31 ++++------ 14 files changed, 104 insertions(+), 141 deletions(-) create mode 100644 netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index c1d856f39ac..4a8e2bd289c 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -31,7 +31,7 @@ class ProviderSerializer(NetBoxModelSerializer): class Meta: model = Provider fields = [ - 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + 'id', 'url', 'display', 'name', 'slug', 'account', 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index cee38fb18ef..cf250584fb6 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -65,7 +65,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = Provider - fields = ['id', 'name', 'slug', 'asn', 'account'] + fields = ['id', 'name', 'slug', 'account'] def search(self, queryset, name, value): if not value.strip(): @@ -73,8 +73,6 @@ def search(self, queryset, name, value): return queryset.filter( Q(name__icontains=value) | Q(account__icontains=value) | - Q(noc_contact__icontains=value) | - Q(admin_contact__icontains=value) | Q(comments__icontains=value) ) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index b6ba42afbde..12975b5d617 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -20,10 +20,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): - asn = forms.IntegerField( - required=False, - label='ASN (legacy)' - ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), @@ -34,20 +30,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False, label='Account number' ) - portal_url = forms.URLField( - required=False, - label='Portal' - ) - noc_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='NOC contact' - ) - admin_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='Admin contact' - ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -55,10 +37,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): model = Provider fieldsets = ( - (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), + (None, ('asns', 'account', )), ) nullable_fields = ( - 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'asns', 'account', 'comments', ) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index cc2d0409a86..77ebb3de918 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm): class Meta: model = Provider fields = ( - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'name', 'slug', 'account', 'comments', ) diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 7bd7abbbff4..17c2e748069 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -30,29 +30,17 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), - ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ('Provider', ('name', 'slug', 'asns', 'tags')), + ('Support Info', ('account',)), ) class Meta: model = Provider fields = [ - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', + 'name', 'slug', 'account', 'asns', 'comments', 'tags', ] - widgets = { - 'noc_contact': SmallTextarea( - attrs={'rows': 5} - ), - 'admin_contact': SmallTextarea( - attrs={'rows': 5} - ), - } help_texts = { 'name': "Full name of the provider", - 'asn': "BGP autonomous system number (if applicable)", - 'portal_url': "URL of the provider's customer support portal", - 'noc_contact': "NOC email address and phone number", - 'admin_contact': "Administrative contact email address and phone number", } diff --git a/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py new file mode 100644 index 00000000000..98c82204d49 --- /dev/null +++ b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py @@ -0,0 +1,59 @@ +import os + +from django.db import migrations +from django.db.utils import DataError + + +def check_legacy_data(apps, schema_editor): + """ + Abort the migration if any legacy provider fields still contain data. + """ + Provider = apps.get_model('circuits', 'Provider') + + provider_count = Provider.objects.exclude(asn__isnull=True).count() + if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting asn field from Provider model: Found {provider_count} " + f"providers with legacy ASN data. Please ensure all legacy provider ASN data has been " + f"migrated to ASN objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " + f"environment variable to bypass this safeguard and delete all legacy provider ASN data." + ) + + provider_count = Provider.objects.exclude(admin_contact='', noc_contact='', portal_url='').count() + if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting contact fields from Provider model: Found {provider_count} " + f"providers with legacy contact data. Please ensure all legacy provider contact data has been " + f"migrated to contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " + f"environment variable to bypass this safeguard and delete all legacy provider contact data." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0039_unique_constraints'), + ] + + operations = [ + migrations.RunPython( + code=check_legacy_data, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='provider', + name='admin_contact', + ), + migrations.RemoveField( + model_name='provider', + name='asn', + ), + migrations.RemoveField( + model_name='provider', + name='noc_contact', + ), + migrations.RemoveField( + model_name='provider', + name='portal_url', + ), + ] diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 2a1e01626a1..bd63ff0c690 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -24,12 +24,6 @@ class Provider(NetBoxModel): max_length=100, unique=True ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) asns = models.ManyToManyField( to='ipam.ASN', related_name='providers', @@ -40,18 +34,6 @@ class Provider(NetBoxModel): blank=True, verbose_name='Account number' ) - portal_url = models.URLField( - blank=True, - verbose_name='Portal URL' - ) - noc_contact = models.TextField( - blank=True, - verbose_name='NOC contact' - ) - admin_contact = models.TextField( - blank=True, - verbose_name='Admin contact' - ) comments = models.TextField( blank=True ) @@ -62,7 +44,7 @@ class Provider(NetBoxModel): ) clone_fields = ( - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + 'account', ) class Meta: diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index 0ec6d439d00..3e2fd1193a3 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -41,10 +41,10 @@ class ProviderTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Provider fields = ( - 'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', + 'pk', 'id', 'name', 'asns', 'account', 'asn_count', 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') + default_columns = ('pk', 'name', 'account', 'circuit_count') class ProviderNetworkTable(NetBoxTable): diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 02b489ac4b0..c9d2cfc4015 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] bulk_update_data = { - 'asn': 1234, + 'account': '1234', } @classmethod diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 2646de3c2d1..897c87c058a 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -25,11 +25,11 @@ def setUpTestData(cls): ASN.objects.bulk_create(asns) providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), - Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), - Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'), - Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'), - Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), + Provider(name='Provider 1', slug='provider-1', account='1234'), + Provider(name='Provider 2', slug='provider-2', account='2345'), + Provider(name='Provider 3', slug='provider-3', account='3456'), + Provider(name='Provider 4', slug='provider-4', account='4567'), + Provider(name='Provider 5', slug='provider-5', account='5678'), ) Provider.objects.bulk_create(providers) providers[0].asns.set([asns[0]]) @@ -82,10 +82,6 @@ def test_slug(self): params = {'slug': ['provider-1', 'provider-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn(self): # Legacy field - params = {'asn': ['65001', '65002']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn_id(self): # ASN object assignment asns = ASN.objects.all()[:2] params = {'asn_id': [asns[0].pk, asns[1].pk]} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index fa6146b934b..9644c0b02f2 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -23,9 +23,9 @@ def setUpTestData(cls): ASN.objects.bulk_create(asns) providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65002), - Provider(name='Provider 3', slug='provider-3', asn=65003), + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), ) Provider.objects.bulk_create(providers) providers[0].asns.set([asns[0], asns[1]]) @@ -37,12 +37,8 @@ def setUpTestData(cls): cls.form_data = { 'name': 'Provider X', 'slug': 'provider-x', - 'asn': 65123, 'asns': [asns[6].pk, asns[7].pk], 'account': '1234', - 'portal_url': 'http://example.com/portal', - 'noc_contact': 'noc@example.com', - 'admin_contact': 'admin@example.com', 'comments': 'Another provider', 'tags': [t.pk for t in tags], } @@ -55,11 +51,7 @@ def setUpTestData(cls): ) cls.bulk_edit_data = { - 'asn': 65009, 'account': '5678', - 'portal_url': 'http://example.com/portal2', - 'noc_contact': 'noc2@example.com', - 'admin_contact': 'admin2@example.com', 'comments': 'New comments', } @@ -104,8 +96,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): def setUpTestData(cls): providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65002), + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), ) Provider.objects.bulk_create(providers) diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidator.py index ce3b572d137..0fe507b673c 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidator.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase, override_settings -from circuits.models import Provider +from ipam.models import ASN, RIR from dcim.models import Site from extras.validators import CustomValidator @@ -67,21 +67,25 @@ def validate(self, instance): class CustomValidatorTest(TestCase): - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) + @classmethod + def setUpTestData(cls): + RIR.objects.create(name='RIR 1', slug='rir-1') + + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_configuration(self): - self.assertIn('circuits.provider', settings.CUSTOM_VALIDATORS) - validator = settings.CUSTOM_VALIDATORS['circuits.provider'][0] + self.assertIn('ipam.asn', settings.CUSTOM_VALIDATORS) + validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0] self.assertIsInstance(validator, CustomValidator) - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_min(self): with self.assertRaises(ValidationError): - Provider(name='Provider 1', slug='provider-1', asn=1).clean() + ASN(asn=1, rir=RIR.objects.first()).clean() - @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [max_validator]}) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [max_validator]}) def test_max(self): with self.assertRaises(ValidationError): - Provider(name='Provider 1', slug='provider-1', asn=65535).clean() + ASN(asn=65535, rir=RIR.objects.first()).clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) def test_min_length(self): diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 60bf8cfbcdc..0fc18a36858 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -19,17 +19,6 @@
Provider
- - - - - - - - - - - - - - - - + + + + + + + + diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 51e873ffaf7..e30ce7a6214 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -104,9 +104,7 @@
-
- Dimensions -
+
Dimensions
ASN - {% if object.asn %} -
- -
- {% endif %} - {{ object.asn|placeholder }} -
ASNs @@ -44,24 +33,6 @@
Provider
Account {{ object.account|placeholder }}
Customer Portal - {% if object.portal_url %} - {{ object.portal_url }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
NOC Contact{{ object.noc_contact|markdown|placeholder }}
Admin Contact{{ object.admin_contact|markdown|placeholder }}
Circuits diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 5182722d1a6..334f270dcb5 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,8 +5,6 @@ from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager -from circuits.filtersets import CircuitFilterSet, ProviderFilterSet -from circuits.models import Circuit, Provider from dcim.choices import * from dcim.fields import MACAddressField from dcim.filtersets import DeviceFilterSet, SiteFilterSet @@ -15,6 +13,7 @@ ) from extras.filters import TagFilter from extras.models import TaggedItem +from ipam.filtersets import ASNFilterSet from ipam.models import RIR, ASN from netbox.filtersets import BaseFilterSet from utilities.filters import ( @@ -338,13 +337,14 @@ class DynamicFilterLookupExpressionTest(TestCase): """ @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RIR 1', slug='rir-1') - providers = ( - Provider(name='Provider 1', slug='provider-1', asn=65001), - Provider(name='Provider 2', slug='provider-2', asn=65101), - Provider(name='Provider 3', slug='provider-3', asn=65201), + asns = ( + ASN(asn=65001, rir=rir), + ASN(asn=65101, rir=rir), + ASN(asn=65201, rir=rir), ) - Provider.objects.bulk_create(providers) + ASN.objects.bulk_create(asns) manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -389,15 +389,6 @@ def setUpTestData(cls): ) Site.objects.bulk_create(sites) - rir = RIR.objects.create(name='RFC 6996', is_private=True) - - asns = [ - ASN(asn=65001, rir=rir), - ASN(asn=65101, rir=rir), - ASN(asn=65201, rir=rir) - ] - ASN.objects.bulk_create(asns) - asns[0].sites.add(sites[0]) asns[1].sites.add(sites[1]) asns[2].sites.add(sites[2]) @@ -456,19 +447,19 @@ def test_site_slug_endswith_negation(self): def test_provider_asn_lt(self): params = {'asn__lt': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1) def test_provider_asn_lte(self): params = {'asn__lte': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 2) def test_provider_asn_gt(self): params = {'asn__lt': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1) def test_provider_asn_gte(self): params = {'asn__gte': [65101]} - self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) + self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} From 00d2dcda68be0935962c8e5d11a66d784638fb1e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 15:56:09 -0400 Subject: [PATCH 027/179] Refactor navigation resources and menu --- docs/development/adding-models.md | 2 +- netbox/netbox/navigation/__init__.py | 92 +++++++++++++++++++ .../menu.py} | 83 +---------------- netbox/utilities/templatetags/navigation.py | 2 +- 4 files changed, 95 insertions(+), 84 deletions(-) create mode 100644 netbox/netbox/navigation/__init__.py rename netbox/netbox/{navigation_menu.py => navigation/menu.py} (86%) diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index f4d171f48a4..aef11d666e3 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -60,7 +60,7 @@ Create the HTML template for the object view. (The other views each typically em ## 10. Add the model to the navigation menu -Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`. +Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`. ## 11. REST API components diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py new file mode 100644 index 00000000000..7b572984342 --- /dev/null +++ b/netbox/netbox/navigation/__init__.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from typing import Sequence, Optional + +from utilities.choices import ButtonColorChoices + + +__all__ = ( + 'get_model_item', + 'get_model_buttons', + 'Menu', + 'MenuGroup', + 'MenuItem', + 'MenuItemButton', +) + + +# +# Navigation menu data classes +# + +@dataclass +class MenuItemButton: + + link: str + title: str + icon_class: str + permissions: Optional[Sequence[str]] = () + color: Optional[str] = None + + +@dataclass +class MenuItem: + + link: str + link_text: str + permissions: Optional[Sequence[str]] = () + buttons: Optional[Sequence[MenuItemButton]] = () + + +@dataclass +class MenuGroup: + + label: str + items: Sequence[MenuItem] + + +@dataclass +class Menu: + + label: str + icon_class: str + groups: Sequence[MenuGroup] + + +# +# Utility functions +# + +def get_model_item(app_label, model_name, label, actions=('add', 'import')): + return MenuItem( + link=f'{app_label}:{model_name}_list', + link_text=label, + permissions=[f'{app_label}.view_{model_name}'], + buttons=get_model_buttons(app_label, model_name, actions) + ) + + +def get_model_buttons(app_label, model_name, actions=('add', 'import')): + buttons = [] + + if 'add' in actions: + buttons.append( + MenuItemButton( + link=f'{app_label}:{model_name}_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=[f'{app_label}.add_{model_name}'], + color=ButtonColorChoices.GREEN + ) + ) + if 'import' in actions: + buttons.append( + MenuItemButton( + link=f'{app_label}:{model_name}_import', + title='Import', + icon_class='mdi mdi-upload', + permissions=[f'{app_label}.add_{model_name}'], + color=ButtonColorChoices.CYAN + ) + ) + + return buttons diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation/menu.py similarity index 86% rename from netbox/netbox/navigation_menu.py rename to netbox/netbox/navigation/menu.py index d4970aa3561..9eb762c23fe 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,86 +1,5 @@ -from dataclasses import dataclass -from typing import Sequence, Optional - from extras.registry import registry -from utilities.choices import ButtonColorChoices - - -# -# Nav menu data classes -# - -@dataclass -class MenuItemButton: - - link: str - title: str - icon_class: str - permissions: Optional[Sequence[str]] = () - color: Optional[str] = None - - -@dataclass -class MenuItem: - - link: str - link_text: str - permissions: Optional[Sequence[str]] = () - buttons: Optional[Sequence[MenuItemButton]] = () - - -@dataclass -class MenuGroup: - - label: str - items: Sequence[MenuItem] - - -@dataclass -class Menu: - - label: str - icon_class: str - groups: Sequence[MenuGroup] - - -# -# Utility functions -# - -def get_model_item(app_label, model_name, label, actions=('add', 'import')): - return MenuItem( - link=f'{app_label}:{model_name}_list', - link_text=label, - permissions=[f'{app_label}.view_{model_name}'], - buttons=get_model_buttons(app_label, model_name, actions) - ) - - -def get_model_buttons(app_label, model_name, actions=('add', 'import')): - buttons = [] - - if 'add' in actions: - buttons.append( - MenuItemButton( - link=f'{app_label}:{model_name}_add', - title='Add', - icon_class='mdi mdi-plus-thick', - permissions=[f'{app_label}.add_{model_name}'], - color=ButtonColorChoices.GREEN - ) - ) - if 'import' in actions: - buttons.append( - MenuItemButton( - link=f'{app_label}:{model_name}_import', - title='Import', - icon_class='mdi mdi-upload', - permissions=[f'{app_label}.add_{model_name}'], - color=ButtonColorChoices.CYAN - ) - ) - - return buttons +from .navigation import * # diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index ef065744632..a34ef981658 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -2,7 +2,7 @@ from django import template from django.template import Context -from netbox.navigation_menu import MENUS +from netbox.navigation.menu import MENUS register = template.Library() From db90b084cf779c109100a6688d0e16ec07cb1978 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 16:08:03 -0400 Subject: [PATCH 028/179] Enable plugins to create root-level navigation menus --- netbox/extras/plugins/__init__.py | 49 +++++++++++++++++++---------- netbox/extras/tests/test_plugins.py | 2 +- netbox/netbox/navigation/menu.py | 42 +++++++++---------------- 3 files changed, 49 insertions(+), 44 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 95e88ca8ced..a5fdbea102c 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -6,15 +6,16 @@ from django.core.exceptions import ImproperlyConfigured from django.template.loader import get_template +from extras.plugins.utils import import_object from extras.registry import registry +from netbox.navigation import MenuGroup from utilities.choices import ButtonColorChoices -from extras.plugins.utils import import_object - # Initialize plugin registry registry['plugins'] = { 'graphql_schemas': [], + 'menus': [], 'menu_items': {}, 'preferences': {}, 'template_extensions': collections.defaultdict(list), @@ -57,8 +58,8 @@ class PluginConfig(AppConfig): # Default integration paths. Plugin authors can override these to customize the paths to # integrated components. graphql_schema = 'graphql.schema' + menu = 'navigation.menu' menu_items = 'navigation.menu_items' - menu_header = 'navigation.menu_heading' template_extensions = 'template_content.template_extensions' user_preferences = 'preferences.preferences' @@ -70,15 +71,11 @@ def ready(self): if template_extensions is not None: register_template_extensions(template_extensions) - # Register navigation menu items (if defined) - try: - menu_header = import_object(f"{self.__module__}.{self.menu_header}") - except AttributeError: - menu_header = None - - menu_items = import_object(f"{self.__module__}.{self.menu_items}") - if menu_items is not None: - register_menu_items(self.verbose_name, menu_header, menu_items) + # Register navigation menu or menu items (if defined) + if menu := import_object(f"{self.__module__}.{self.menu}"): + register_menu(menu) + if menu_items := import_object(f"{self.__module__}.{self.menu_items}"): + register_menu_items(self.verbose_name, menu_items) # Register GraphQL schema (if defined) graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}") @@ -206,6 +203,22 @@ def register_template_extensions(class_list): # Navigation menu links # +class PluginMenu: + icon = 'mdi-puzzle' + + def __init__(self, label, groups, icon=None): + self.label = label + self.groups = [ + MenuGroup(label, items) for label, items in groups + ] + if icon is not None: + self.icon = icon + + @property + def icon_class(self): + return f'mdi {self.icon}' + + class PluginMenuItem: """ This class represents a navigation menu item. This constitutes primary link and its text, but also allows for @@ -252,7 +265,13 @@ def __init__(self, link, title, icon_class, color=None, permissions=None): self.color = color -def register_menu_items(section_name, menu_header, class_list): +def register_menu(menu): + if not isinstance(menu, PluginMenu): + raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") + registry['plugins']['menus'].append(menu) + + +def register_menu_items(section_name, class_list): """ Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) """ @@ -264,9 +283,7 @@ def register_menu_items(section_name, menu_header, class_list): if not isinstance(button, PluginMenuButton): raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - registry['plugins']['menu_items'][section_name] = {} - registry['plugins']['menu_items'][section_name]['header'] = menu_header - registry['plugins']['menu_items'][section_name]['items'] = class_list + registry['plugins']['menu_items'][section_name] = class_list # diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 733ae3a39b3..299cab9efa0 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -63,7 +63,7 @@ def test_menu_items(self): Check that plugin MenuItems and MenuButtons are registered. """ self.assertIn('Dummy plugin', registry['plugins']['menu_items']) - menu_items = registry['plugins']['menu_items']['Dummy plugin']['items'] + menu_items = registry['plugins']['menu_items']['Dummy plugin'] self.assertEqual(len(menu_items), 2) self.assertEqual(len(menu_items[0].buttons), 2) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 9eb762c23fe..400a7bf5a40 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,5 +1,5 @@ from extras.registry import registry -from .navigation import * +from . import * # @@ -324,31 +324,19 @@ # Add plugin menus # -if registry['plugins']['menu_items']: - plugin_menu_groups = [] - - for plugin_name, data in registry['plugins']['menu_items'].items(): - if data['header']: - menu_groups = [MenuGroup(label=plugin_name, items=data["items"])] - icon = data["header"]["icon"] - MENUS.append(Menu( - label=data["header"]["title"], - icon_class=f"mdi {icon}", - groups=menu_groups - )) - else: - plugin_menu_groups.append( - MenuGroup( - label=plugin_name, - items=data["items"] - ) - ) +for menu in registry['plugins']['menus']: + MENUS.append(menu) - if plugin_menu_groups: - PLUGIN_MENU = Menu( - label="Plugins", - icon_class="mdi mdi-puzzle", - groups=plugin_menu_groups - ) +if registry['plugins']['menu_items']: - MENUS.append(PLUGIN_MENU) + # Build the default plugins menu + groups = [ + MenuGroup(label=label, items=items) + for label, items in registry['plugins']['menu_items'].items() + ] + plugins_menu = Menu( + label="Plugins", + icon_class="mdi mdi-puzzle", + groups=groups + ) + MENUS.append(plugins_menu) From d0465242a336061b5894299037a10125f0f8c438 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 16:44:16 -0400 Subject: [PATCH 029/179] Add documentation for PluginMenu --- docs/plugins/development/navigation.md | 107 ++++++++++++++----------- netbox/extras/plugins/__init__.py | 12 +-- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index b4a872ae2bd..a52a9803a00 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -1,85 +1,94 @@ # Navigation -## Menu Items +## Menus -To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. +!!! note + This feature was introduced in NetBox v3.4. -!!! tip - The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance. +A plugin can register its own submenu as part of NetBox's navigation menu. This is done by defining a variable named `menu` in `navigation.py`, pointing to an instance of the `PluginMenu` class. Each menu must define a label and grouped menu items (discussed below), and may optionally specify an icon. An example is shown below. -```python -from extras.plugins import PluginMenuButton, PluginMenuItem -from utilities.choices import ButtonColorChoices +```python title="navigation.py" +from extras.plugins import PluginMenu -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) +menu = PluginMenu( + label='My Plugin', + groups=( + ('Foo', (item1, item2, item3)), + ('Bar', (item4, item5)), ), + icon='mdi mdi-router' ) ``` -A `PluginMenuItem` has the following attributes: +Note that each group is a two-tuple containing a label and an iterable of menu items. The group's label serves as the section header within the submenu. A group label is required even if you have only one group of items. -| Attribute | Required | Description | -|---------------|----------|------------------------------------------------------| -| `link` | Yes | Name of the URL path to which this menu item links | -| `link_text` | Yes | The text presented to the user | -| `permissions` | - | A list of permissions required to display this link | -| `buttons` | - | An iterable of PluginMenuButton instances to include | +!!! tip + The path to the menu class can be modified by setting `menu` in the PluginConfig instance. -## Optional Header +A `PluginMenu` has the following attributes: -Plugin menus normally appear under the "Plugins" header. An optional menu_heading can be defined to make the plugin menu to appear as a top level menu header. An example is shown below: +| Attribute | Required | Description | +|--------------|----------|---------------------------------------------------| +| `label` | Yes | The text displayed as the menu heading | +| `groups` | Yes | An iterable of named groups containing menu items | +| `icon_class` | - | The CSS name of the icon to use for the heading | -```python +!!! tip + Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/) + +### The Default Menu + +If your plugin has only a small number of menu items, it may be desirable to use NetBox's shared "Plugins" menu rather than creating your own. To do this, simply declare `menu_items` as a list of `PluginMenuItems` in `navigation.py`. The listed items will appear under a heading bearing the name of your plugin in the "Plugins" submenu. + +```python title="navigation.py" +menu_items = (item1, item2, item3) +``` + +!!! tip + The path to the menu items list can be modified by setting `menu_items` in the PluginConfig instance. + +## Menu Items + +Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below. + +```python filename="navigation.py" from extras.plugins import PluginMenuButton, PluginMenuItem from utilities.choices import ButtonColorChoices -menu_heading = { - "title": "Animal Sound", - "icon": "mdi-puzzle" -} - -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) - ), +item1 = PluginMenuItem( + link='plugins:myplugin:myview', + link_text='Some text', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) ) ``` -The `menu_heading` has the following attributes: +A `PluginMenuItem` has the following attributes: | Attribute | Required | Description | |---------------|----------|------------------------------------------------------| -| `title` | Yes | The text that will show in the menu header | -| `icon` | Yes | The icon to use next to the headermdi | - -!!! tip - The icon names can be found at [Material Design Icons](https://materialdesignicons.com/) +| `link` | Yes | Name of the URL path to which this menu item links | +| `link_text` | Yes | The text presented to the user | +| `permissions` | - | A list of permissions required to display this link | +| `buttons` | - | An iterable of PluginMenuButton instances to include | ## Menu Buttons +Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects. + A `PluginMenuButton` has the following attributes: | Attribute | Required | Description | |---------------|----------|--------------------------------------------------------------------| | `link` | Yes | Name of the URL path to which this button links | | `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) | -| `icon_class` | Yes | Button icon CSS class* | +| `icon_class` | Yes | Button icon CSS class | | `color` | - | One of the choices provided by `ButtonColorChoices` | | `permissions` | - | A list of permissions required to display this button | -*NetBox supports [Material Design Icons](https://materialdesignicons.com/). +Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. -!!! note - Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. +!!! tip + Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index a5fdbea102c..9fdf172e3c5 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -204,19 +204,15 @@ def register_template_extensions(class_list): # class PluginMenu: - icon = 'mdi-puzzle' + icon_class = 'mdi-puzzle' - def __init__(self, label, groups, icon=None): + def __init__(self, label, groups, icon_class=None): self.label = label self.groups = [ MenuGroup(label, items) for label, items in groups ] - if icon is not None: - self.icon = icon - - @property - def icon_class(self): - return f'mdi {self.icon}' + if icon_class is not None: + self.icon_class = icon_class class PluginMenuItem: From 3fbd514417755f886d09a352b3fc1a4f7240430c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 16:57:40 -0400 Subject: [PATCH 030/179] Add test for plugin menu registration --- netbox/extras/plugins/__init__.py | 2 +- netbox/extras/tests/dummy_plugin/navigation.py | 10 ++++++++-- netbox/extras/tests/test_plugins.py | 11 ++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 9fdf172e3c5..ef1106aeac3 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -204,7 +204,7 @@ def register_template_extensions(class_list): # class PluginMenu: - icon_class = 'mdi-puzzle' + icon_class = 'mdi mdi-puzzle' def __init__(self, label, groups, icon_class=None): self.label = label diff --git a/netbox/extras/tests/dummy_plugin/navigation.py b/netbox/extras/tests/dummy_plugin/navigation.py index 88ac3f7c92e..a475b1cdecd 100644 --- a/netbox/extras/tests/dummy_plugin/navigation.py +++ b/netbox/extras/tests/dummy_plugin/navigation.py @@ -1,7 +1,7 @@ -from extras.plugins import PluginMenuButton, PluginMenuItem +from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem -menu_items = ( +items = ( PluginMenuItem( link='plugins:dummy_plugin:dummy_models', link_text='Item 1', @@ -23,3 +23,9 @@ link_text='Item 2', ), ) + +menu = PluginMenu( + label='Dummy', + groups=(('Group 1', items),), +) +menu_items = items diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 299cab9efa0..e0ff67a2b6c 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,6 +5,7 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse +from extras.plugins import PluginMenu from extras.registry import registry from extras.tests.dummy_plugin import config as dummy_config from netbox.graphql.schema import Query @@ -58,9 +59,17 @@ def test_api_views(self): response = client.get(url) self.assertEqual(response.status_code, 200) + def test_menu(self): + """ + Check menu registration. + """ + menu = registry['plugins']['menus'][0] + self.assertIsInstance(menu, PluginMenu) + self.assertEqual(menu.label, 'Dummy') + def test_menu_items(self): """ - Check that plugin MenuItems and MenuButtons are registered. + Check menu_items registration. """ self.assertIn('Dummy plugin', registry['plugins']['menu_items']) menu_items = registry['plugins']['menu_items']['Dummy plugin'] From d486fa8452b8516ffc33556c183f3f312c701a14 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Sep 2022 17:18:31 -0400 Subject: [PATCH 031/179] Changelog for #9045, #9046, #9071 --- docs/release-notes/version-3.4.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 98a576c70da..24e5a0ea9b6 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -6,6 +6,14 @@ ### Breaking Changes * Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error. +* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. +* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. + +### New Features + +#### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071)) + +A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained. ### Enhancements @@ -14,13 +22,18 @@ ### Plugins API +* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin ### Other Changes +* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model +* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 ### REST API Changes +* circuits.provider + * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields * ipam.FHRPGroup * Added optional `name` field From ada5c58acffa2528bee1f82b4daabb77ae147d9f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 15:05:13 -0400 Subject: [PATCH 032/179] Closes #10529: Run validation on each value of a multi-value filter --- netbox/utilities/filters.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 543449b73e4..3d7f7d7ad7f 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -23,6 +23,14 @@ def to_python(self, value): field.to_python(v) for v in value if v ] + def run_validators(self, value): + for v in value: + super().run_validators(v) + + def validate(self, value): + for v in value: + super().validate(v) + return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) From af8bb0c4b9c0b8d6599f110f522bd1e598c40b88 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 30 Sep 2022 13:03:24 -0700 Subject: [PATCH 033/179] 10348 add decimal custom field (#10422) * 10348 add decimal custom field * 10348 fix tests * 10348 add documentation * Rearrange custom fields to be ordered consistently * Rename number_field to integer_field for clarity * Clean up validation logic * Apply suggested changes from PR * Store decimal custom field values natively * Fix filter test * Update custom field model migrations to use new encoder Co-authored-by: jeremystretch --- docs/customization/custom-fields.md | 1 + netbox/circuits/migrations/0001_squashed.py | 10 +- ...uit_termination_date_tags_custom_fields.py | 4 +- netbox/dcim/migrations/0001_squashed.py | 52 +-- netbox/dcim/migrations/0146_modules.py | 8 +- .../dcim/migrations/0147_inventoryitemrole.py | 4 +- netbox/extras/api/serializers.py | 2 + netbox/extras/choices.py | 2 + .../0073_journalentry_tags_custom_fields.py | 4 +- netbox/extras/models/customfields.py | 50 ++- netbox/extras/tests/test_customfields.py | 300 +++++++++++------- netbox/extras/tests/test_forms.py | 3 + netbox/ipam/migrations/0001_squashed.py | 22 +- netbox/ipam/migrations/0050_iprange.py | 4 +- netbox/ipam/migrations/0052_fhrpgroup.py | 4 +- netbox/ipam/migrations/0053_asn_model.py | 4 +- .../ipam/migrations/0055_servicetemplate.py | 4 +- netbox/ipam/migrations/0059_l2vpn.py | 6 +- netbox/netbox/filtersets.py | 3 +- netbox/netbox/models/features.py | 4 +- .../tenancy/migrations/0001_squashed_0012.py | 6 +- netbox/tenancy/migrations/0003_contacts.py | 8 +- netbox/utilities/filters.py | 8 +- netbox/utilities/json.py | 17 + .../migrations/0001_squashed_0022.py | 12 +- netbox/wireless/migrations/0001_wireless.py | 8 +- 26 files changed, 343 insertions(+), 207 deletions(-) create mode 100644 netbox/utilities/json.py diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index c443fa9f6fa..81aaa52471c 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -13,6 +13,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * Text: Free-form text (intended for single-line use) * Long text: Free-form of any length; supports Markdown rendering * Integer: A whole number (positive or negative) +* Decimal: A fixed-precision decimal number (4 decimal places) * Boolean: True or false * Date: A date in ISO 8601 format (YYYY-MM-DD) * URL: This will be presented as a link in the web UI diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py index 851f40a220f..97123316243 100644 --- a/netbox/circuits/migrations/0001_squashed.py +++ b/netbox/circuits/migrations/0001_squashed.py @@ -1,5 +1,5 @@ import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('cid', models.CharField(max_length=100)), ('status', models.CharField(default='active', max_length=50)), @@ -58,7 +58,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -73,7 +73,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -93,7 +93,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('description', models.CharField(blank=True, max_length=200)), diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py index c686bf0429b..96b2a9d9766 100644 --- a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py +++ b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='circuittermination', name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder), ), migrations.AddField( model_name='circuittermination', diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index 374d3bf452b..fca7d8eb9b0 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -1,6 +1,6 @@ import dcim.fields import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -28,7 +28,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('termination_a_id', models.PositiveIntegerField()), ('termination_b_id', models.PositiveIntegerField()), @@ -60,7 +60,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -96,7 +96,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -132,7 +132,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('name', models.CharField(blank=True, max_length=64, null=True)), @@ -155,7 +155,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -186,7 +186,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -203,7 +203,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('model', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -224,7 +224,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -261,7 +261,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('label', models.CharField(blank=True, max_length=64)), @@ -302,7 +302,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -326,7 +326,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -345,7 +345,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -360,7 +360,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -377,7 +377,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)), ('mark_connected', models.BooleanField(default=False)), @@ -401,7 +401,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -438,7 +438,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ], @@ -451,7 +451,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -490,7 +490,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -516,7 +516,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), ('description', models.CharField(max_length=200)), @@ -530,7 +530,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -546,7 +546,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -583,7 +583,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -602,7 +602,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -630,7 +630,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -649,7 +649,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('domain', models.CharField(blank=True, max_length=30)), diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py index 11324fc5882..821cf6119b6 100644 --- a/netbox/dcim/migrations/0146_modules.py +++ b/netbox/dcim/migrations/0146_modules.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -107,7 +107,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('model', models.CharField(max_length=100)), ('part_number', models.CharField(blank=True, max_length=50)), @@ -125,7 +125,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), @@ -145,7 +145,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('serial', models.CharField(blank=True, max_length=50)), diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py index f5e1f23f57d..cbdd36c08ff 100644 --- a/netbox/dcim/migrations/0147_inventoryitemrole.py +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 764c7750af8..fd774f8ff84 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -99,6 +99,8 @@ def get_data_type(self, obj): 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 123fd2cd4f5..5afe9f33fb3 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/migrations/0073_journalentry_tags_custom_fields.py b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py index 73a3e466c2a..5f2d7f7f36f 100644 --- a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py +++ b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import taggit.managers @@ -13,7 +13,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='journalentry', name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder), ), migrations.AddField( model_name='journalentry', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 43c4f967197..3cb5b506c43 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 @@ -219,14 +220,11 @@ def clean(self): }) # Minimum/maximum values can be set only for numeric fields - if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: - 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: - raise ValidationError({ - 'validation_maximum': "A maximum value may be set only for numeric fields" - }) + if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL): + if self.validation_minimum: + raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"}) + if self.validation_maximum: + raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"}) # Regex validation can be set only for text fields regex_types = ( @@ -317,6 +315,17 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= max_value=self.validation_maximum ) + # Decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + field = forms.DecimalField( + required=required, + initial=initial, + max_digits=12, + decimal_places=4, + min_value=self.validation_minimum, + max_value=self.validation_maximum + ) + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( @@ -426,6 +435,10 @@ def to_filter(self, lookup_expr=None): elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: filter_class = filters.MultiValueNumberFilter + # Decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + filter_class = filters.MultiValueDecimalFilter + # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: filter_class = django_filters.BooleanFilter @@ -475,7 +488,7 @@ def validate(self, value): 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: @@ -483,12 +496,23 @@ def validate(self, value): if self.validation_maximum is not None and value > self.validation_maximum: raise ValidationError(f"Value must not exceed {self.validation_maximum}") + # Validate decimal + elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL: + try: + decimal.Decimal(value) + except decimal.InvalidOperation: + 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]: + 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') @@ -496,14 +520,14 @@ def validate(self, value): 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 946999bc29f..6080ce2e5d3 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse @@ -102,6 +104,32 @@ def test_integer_field(self): 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 @@ -373,7 +401,8 @@ def setUpTestData(cls): custom_fields = ( CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), - CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45), CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), @@ -424,14 +453,15 @@ def setUpTestData(cls): custom_fields[0].name: 'bar', custom_fields[1].name: 'DEF', custom_fields[2].name: 456, - custom_fields[3].name: True, - custom_fields[4].name: '2020-01-02', - custom_fields[5].name: 'http://example.com/2', - custom_fields[6].name: '{"foo": 1, "bar": 2}', - custom_fields[7].name: 'Bar', - custom_fields[8].name: ['Bar', 'Baz'], - custom_fields[9].name: vlans[1].pk, - custom_fields[10].name: [vlans[2].pk, vlans[3].pk], + custom_fields[3].name: Decimal('456.78'), + custom_fields[4].name: True, + custom_fields[5].name: '2020-01-02', + custom_fields[6].name: 'http://example.com/2', + custom_fields[7].name: '{"foo": 1, "bar": 2}', + custom_fields[8].name: 'Bar', + custom_fields[9].name: ['Bar', 'Baz'], + custom_fields[10].name: vlans[1].pk, + custom_fields[11].name: [vlans[2].pk, vlans[3].pk], } sites[1].save() @@ -440,6 +470,7 @@ def test_get_custom_fields(self): CustomFieldTypeChoices.TYPE_TEXT: 'string', CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', CustomFieldTypeChoices.TYPE_INTEGER: 'integer', + CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal', CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', CustomFieldTypeChoices.TYPE_DATE: 'string', CustomFieldTypeChoices.TYPE_URL: 'string', @@ -473,7 +504,8 @@ def test_get_single_object_without_custom_field_data(self): self.assertEqual(response.data['custom_fields'], { 'text_field': None, 'longtext_field': None, - 'number_field': None, + 'integer_field': None, + 'decimal_field': None, 'boolean_field': None, 'date_field': None, 'url_field': None, @@ -497,7 +529,8 @@ def test_get_single_object_with_custom_field_data(self): self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) - self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) + self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field']) + self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field']) self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field']) self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) @@ -531,7 +564,8 @@ def test_create_single_object_with_defaults(self): response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) @@ -548,7 +582,8 @@ def test_create_single_object_with_defaults(self): site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) @@ -568,7 +603,8 @@ def test_create_single_object_with_values(self): 'custom_fields': { 'text_field': 'bar', 'longtext_field': 'blah blah blah', - 'number_field': 456, + 'integer_field': 456, + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -590,7 +626,8 @@ def test_create_single_object_with_values(self): data_cf = data['custom_fields'] self.assertEqual(response_cf['text_field'], data_cf['text_field']) self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field']) - self.assertEqual(response_cf['number_field'], data_cf['number_field']) + self.assertEqual(response_cf['integer_field'], data_cf['integer_field']) + self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) @@ -607,7 +644,8 @@ def test_create_single_object_with_values(self): site = Site.objects.get(pk=response.data['id']) self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) @@ -652,7 +690,8 @@ def test_create_multiple_objects_with_defaults(self): response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field']) + self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) @@ -669,7 +708,8 @@ def test_create_multiple_objects_with_defaults(self): site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) @@ -686,7 +726,8 @@ def test_create_multiple_objects_with_values(self): custom_field_data = { 'text_field': 'bar', 'longtext_field': 'abcdefghij', - 'number_field': 456, + 'integer_field': 456, + 'decimal_field': 456.78, 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', @@ -726,7 +767,8 @@ def test_create_multiple_objects_with_values(self): response_cf = response.data[i]['custom_fields'] self.assertEqual(response_cf['text_field'], custom_field_data['text_field']) self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field']) - self.assertEqual(response_cf['number_field'], custom_field_data['number_field']) + self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field']) + self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) @@ -743,7 +785,8 @@ def test_create_multiple_objects_with_values(self): site = Site.objects.get(pk=response.data[i]['id']) self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field']) self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field']) - self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field']) + self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field']) + self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field']) self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) @@ -763,7 +806,7 @@ def test_update_single_object_with_values(self): data = { 'custom_fields': { 'text_field': 'ABCD', - 'number_field': 1234, + 'integer_field': 1234, }, } url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) @@ -775,8 +818,9 @@ def test_update_single_object_with_values(self): # Validate response data response_cf = response.data['custom_fields'] self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field']) - self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field']) self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field']) self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) @@ -792,8 +836,9 @@ def test_update_single_object_with_values(self): # Validate database data site2.refresh_from_db() self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field']) self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.custom_field_data['integer_field'], data['custom_fields']['integer_field']) + self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_field']) self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) @@ -808,20 +853,20 @@ def test_minimum_maximum_values_validation(self): url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - cf_integer = CustomField.objects.get(name='number_field') + cf_integer = CustomField.objects.get(name='integer_field') cf_integer.validation_minimum = 10 cf_integer.validation_maximum = 20 cf_integer.save() - data = {'custom_fields': {'number_field': 9}} + data = {'custom_fields': {'integer_field': 9}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - data = {'custom_fields': {'number_field': 21}} + data = {'custom_fields': {'integer_field': 21}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - data = {'custom_fields': {'number_field': 15}} + data = {'custom_fields': {'integer_field': 15}} response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -860,6 +905,7 @@ def setUpTestData(cls): CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), + CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), @@ -880,10 +926,10 @@ def test_import(self): 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_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) @@ -893,10 +939,11 @@ def test_import(self): # 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) + self.assertEqual(site1.custom_field_data['decimal'], 123.45) self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') @@ -906,10 +953,11 @@ def test_import(self): # 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) + self.assertEqual(site2.custom_field_data['decimal'], 456.78) self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') @@ -1034,53 +1082,78 @@ def setUpTestData(cls): cf.save() cf.content_types.set([obj_type]) + # Decimal filtering + cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL) + cf.save() + cf.content_types.set([obj_type]) + # Boolean filtering - cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN) + cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf.save() cf.content_types.set([obj_type]) # Exact text filtering - cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf4', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose text filtering - cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf5', + type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) # Date filtering - cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE) + cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE) cf.save() cf.content_types.set([obj_type]) # Exact URL filtering - cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf = CustomField( + name='cf7', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + ) cf.save() cf.content_types.set([obj_type]) # Loose URL filtering - cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf = CustomField( + name='cf8', + type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + ) cf.save() cf.content_types.set([obj_type]) # Selection filtering - cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) + cf = CustomField( + name='cf9', + type=CustomFieldTypeChoices.TYPE_SELECT, + choices=['Foo', 'Bar', 'Baz'] + ) cf.save() cf.content_types.set([obj_type]) # Multiselect filtering - cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) + cf = CustomField( + name='cf10', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=['A', 'B', 'C', 'X'] + ) cf.save() cf.content_types.set([obj_type]) # Object filtering cf = CustomField( - name='cf10', + name='cf11', type=CustomFieldTypeChoices.TYPE_OBJECT, object_type=ContentType.objects.get_for_model(Manufacturer) ) @@ -1089,7 +1162,7 @@ def setUpTestData(cls): # Multi-object filtering cf = CustomField( - name='cf11', + name='cf12', type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, object_type=ContentType.objects.get_for_model(Manufacturer) ) @@ -1099,42 +1172,45 @@ def setUpTestData(cls): Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', custom_field_data={ 'cf1': 100, - 'cf2': True, - 'cf3': 'foo', + 'cf2': 100.1, + 'cf3': True, 'cf4': 'foo', - 'cf5': '2016-06-26', - 'cf6': 'http://a.example.com', + 'cf5': 'foo', + 'cf6': '2016-06-26', 'cf7': 'http://a.example.com', - 'cf8': 'Foo', - 'cf9': ['A', 'X'], - 'cf10': manufacturers[0].pk, - 'cf11': [manufacturers[0].pk, manufacturers[3].pk], + 'cf8': 'http://a.example.com', + 'cf9': 'Foo', + 'cf10': ['A', 'X'], + 'cf11': manufacturers[0].pk, + 'cf12': [manufacturers[0].pk, manufacturers[3].pk], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, - 'cf2': True, - 'cf3': 'foobar', + 'cf2': 200.2, + 'cf3': True, 'cf4': 'foobar', - 'cf5': '2016-06-27', - 'cf6': 'http://b.example.com', + 'cf5': 'foobar', + 'cf6': '2016-06-27', 'cf7': 'http://b.example.com', - 'cf8': 'Bar', - 'cf9': ['B', 'X'], - 'cf10': manufacturers[1].pk, - 'cf11': [manufacturers[1].pk, manufacturers[3].pk], + 'cf8': 'http://b.example.com', + 'cf9': 'Bar', + 'cf10': ['B', 'X'], + 'cf11': manufacturers[1].pk, + 'cf12': [manufacturers[1].pk, manufacturers[3].pk], }), Site(name='Site 3', slug='site-3', custom_field_data={ 'cf1': 300, - 'cf2': False, - 'cf3': 'bar', + 'cf2': 300.3, + 'cf3': False, 'cf4': 'bar', - 'cf5': '2016-06-28', - 'cf6': 'http://c.example.com', + 'cf5': 'bar', + 'cf6': '2016-06-28', 'cf7': 'http://c.example.com', - 'cf8': 'Baz', - 'cf9': ['C', 'X'], - 'cf10': manufacturers[2].pk, - 'cf11': [manufacturers[2].pk, manufacturers[3].pk], + 'cf8': 'http://c.example.com', + 'cf9': 'Baz', + 'cf10': ['C', 'X'], + 'cf11': manufacturers[2].pk, + 'cf12': [manufacturers[2].pk, manufacturers[3].pk], }), ]) @@ -1146,60 +1222,68 @@ def test_filter_integer(self): 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_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, 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) + self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1) def test_filter_text_strict(self): - self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2) def test_filter_text_loose(self): - self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2) def test_filter_date(self): - self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) def test_filter_url_strict(self): - self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3) - self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0) - self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3) - self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0) - self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) - self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0) + self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) def test_filter_url_loose(self): - self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) def test_filter_select(self): - self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): - self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3) def test_filter_object(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) - self.assertEqual(self.filterset({'cf_cf10': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) def test_filter_multiobject(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) - self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 1ec50b7ddd2..35402bda341 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -23,6 +23,9 @@ def setUpTestData(cls): 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]) diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index 545fd46c61f..b5d68439abd 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -1,5 +1,5 @@ import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -29,7 +29,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('prefix', ipam.fields.IPNetworkField()), ('date_added', models.DateField(blank=True, null=True)), @@ -44,7 +44,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('address', ipam.fields.IPAddressField()), ('status', models.CharField(default='active', max_length=50)), @@ -64,7 +64,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('prefix', ipam.fields.IPNetworkField()), ('status', models.CharField(default='active', max_length=50)), @@ -81,7 +81,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -99,7 +99,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -115,7 +115,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=21, unique=True)), ('description', models.CharField(blank=True, max_length=200)), @@ -129,7 +129,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('rd', models.CharField(blank=True, max_length=21, null=True, unique=True)), @@ -151,7 +151,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -170,7 +170,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])), ('name', models.CharField(max_length=64)), @@ -193,7 +193,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('protocol', models.CharField(max_length=50)), diff --git a/netbox/ipam/migrations/0050_iprange.py b/netbox/ipam/migrations/0050_iprange.py index 5b8861f29b9..374b2547ca3 100644 --- a/netbox/ipam/migrations/0050_iprange.py +++ b/netbox/ipam/migrations/0050_iprange.py @@ -1,6 +1,6 @@ # Generated by Django 3.2.5 on 2021-07-16 14:15 -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import django.db.models.expressions @@ -22,7 +22,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('start_address', ipam.fields.IPAddressField()), ('end_address', ipam.fields.IPAddressField()), diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 70219543fd9..e69e49d485c 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('group_id', models.PositiveSmallIntegerField()), ('protocol', models.CharField(max_length=50)), diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py index 1c7ee8e23c9..3b074634c59 100644 --- a/netbox/ipam/migrations/0053_asn_model.py +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.8 on 2021-11-02 16:16 import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('asn', dcim.fields.ASNField(unique=True)), ('description', models.CharField(blank=True, max_length=200)), diff --git a/netbox/ipam/migrations/0055_servicetemplate.py b/netbox/ipam/migrations/0055_servicetemplate.py index 738317907a9..c8ba6645c3f 100644 --- a/netbox/ipam/migrations/0055_servicetemplate.py +++ b/netbox/ipam/migrations/0055_servicetemplate.py @@ -1,5 +1,5 @@ import django.contrib.postgres.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import taggit.managers @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('protocol', models.CharField(max_length=50)), ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)), diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py index bd476159309..59dbab632e1 100644 --- a/netbox/ipam/migrations/0059_l2vpn.py +++ b/netbox/ipam/migrations/0059_l2vpn.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -20,7 +20,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField()), ('type', models.CharField(max_length=50)), @@ -42,7 +42,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('assigned_object_id', models.PositiveBigIntegerField()), ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index b6776e3c1a2..6a8f5d0d3be 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -46,7 +46,7 @@ class BaseFilterSet(django_filters.FilterSet): 'filter_class': filters.MultiValueDateTimeFilter }, models.DecimalField: { - 'filter_class': filters.MultiValueNumberFilter + 'filter_class': filters.MultiValueDecimalFilter }, models.EmailField: { 'filter_class': filters.MultiValueCharFilter @@ -95,6 +95,7 @@ def _get_filter_lookup_dict(existing_filter): filters.MultiValueDateFilter, filters.MultiValueDateTimeFilter, filters.MultiValueNumberFilter, + filters.MultiValueDecimalFilter, filters.MultiValueTimeFilter )): return FILTER_NUMERIC_BASED_LOOKUP_MAP diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 9fa1c5cef5f..ce80cec3e72 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -4,7 +4,6 @@ from django.db.models.signals import class_prepared from django.dispatch import receiver -from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager @@ -12,6 +11,7 @@ from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import is_taggable, register_features from netbox.signals import post_clean +from utilities.json import CustomFieldJSONEncoder from utilities.utils import serialize_object __all__ = ( @@ -124,7 +124,7 @@ class CustomFieldsMixin(models.Model): Enables support for custom fields. """ custom_field_data = models.JSONField( - encoder=DjangoJSONEncoder, + encoder=CustomFieldJSONEncoder, blank=True, default=dict ) diff --git a/netbox/tenancy/migrations/0001_squashed_0012.py b/netbox/tenancy/migrations/0001_squashed_0012.py index 77297b98260..e8a028a921d 100644 --- a/netbox/tenancy/migrations/0001_squashed_0012.py +++ b/netbox/tenancy/migrations/0001_squashed_0012.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -34,7 +34,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -54,7 +54,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index 35e568ab1b3..ba9bef50fbe 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -34,7 +34,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100)), @@ -55,7 +55,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100)), ('title', models.CharField(blank=True, max_length=100)), diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 3d7f7d7ad7f..d41eff4989f 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,8 +3,6 @@ from django.conf import settings from django_filters.constants import EMPTY_VALUES -from utilities.forms import MACAddressField - def multivalue_field_factory(field_class): """ @@ -31,7 +29,7 @@ def validate(self, value): for v in value: super().validate(v) - return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) + return type(f'MultiValue{field_class.__name__}', (NewField,), dict()) # @@ -54,6 +52,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) diff --git a/netbox/utilities/json.py b/netbox/utilities/json.py new file mode 100644 index 00000000000..5574ff36f78 --- /dev/null +++ b/netbox/utilities/json.py @@ -0,0 +1,17 @@ +import decimal + +from django.core.serializers.json import DjangoJSONEncoder + +__all__ = ( + 'CustomFieldJSONEncoder', +) + + +class CustomFieldJSONEncoder(DjangoJSONEncoder): + """ + Override Django's built-in JSON encoder to save decimal values as JSON numbers. + """ + def default(self, o): + if isinstance(o, decimal.Decimal): + return float(o) + return super().default(o) diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index d00bae2e2c8..29eda8a506e 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -1,5 +1,5 @@ import dcim.fields -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -51,7 +51,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('comments', models.TextField(blank=True)), @@ -65,7 +65,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -80,7 +80,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -95,7 +95,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('local_context_data', models.JSONField(blank=True, null=True)), ('name', models.CharField(max_length=64)), @@ -147,7 +147,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('enabled', models.BooleanField(default=True)), ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 10b6e585bee..9369df8a5c1 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -1,4 +1,4 @@ -import django.core.serializers.json +from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion import mptt.fields @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), @@ -44,7 +44,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(max_length=32)), ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')), @@ -65,7 +65,7 @@ class Migration(migrations.Migration): fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(blank=True, max_length=32)), ('status', models.CharField(default='connected', max_length=50)), From 204c10c053fddc26ad23ec15a3c60eee38bfc081 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 30 Sep 2022 13:31:04 -0700 Subject: [PATCH 034/179] 9654 device weight (#10448) * 9654 add weight fields to devices * 9654 changes from code review * 9654 change _abs_weight to grams * Resolve migrations conflict * 9654 code-review changes * 9654 total weight on devices * Misc cleanup Co-authored-by: Jeremy Stretch --- docs/models/dcim/devicetype.md | 4 ++ docs/models/dcim/moduletype.md | 4 ++ docs/models/dcim/rack.md | 4 ++ netbox/dcim/api/serializers.py | 16 +++-- netbox/dcim/choices.py | 18 ++++++ netbox/dcim/filtersets.py | 6 +- netbox/dcim/forms/bulk_edit.py | 54 ++++++++++++----- netbox/dcim/forms/filtersets.py | 24 ++++++++ netbox/dcim/forms/models.py | 17 ++++-- netbox/dcim/graphql/types.py | 9 +++ ...0163_rack_devicetype_moduletype_weights.py | 58 +++++++++++++++++++ netbox/dcim/models/devices.py | 24 ++++++-- netbox/dcim/models/mixins.py | 45 ++++++++++++++ netbox/dcim/models/racks.py | 24 +++++++- netbox/dcim/tables/devicetypes.py | 8 ++- netbox/dcim/tables/modules.py | 7 ++- netbox/dcim/tables/racks.py | 11 +++- netbox/dcim/tables/template_code.py | 5 ++ netbox/dcim/tests/test_filtersets.py | 42 +++++++++++--- netbox/templates/dcim/devicetype.html | 10 ++++ netbox/templates/dcim/moduletype.html | 10 ++++ netbox/templates/dcim/rack.html | 19 +++++- netbox/templates/dcim/rack_edit.html | 8 +++ netbox/utilities/utils.py | 27 ++++++++- 24 files changed, 397 insertions(+), 57 deletions(-) create mode 100644 netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py create mode 100644 netbox/dcim/models/mixins.py diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index 050f9324477..6dc4aa13e9a 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -41,6 +41,10 @@ Indicates whether this is a parent type (capable of housing child devices), a ch The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules). +### Weight + +The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds). + ### Front & Rear Images Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index b8ec0ac6ef7..3122d2e00cd 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu ### Part Number An alternative part number to uniquely identify the module type. + +### Weight + +The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound). diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 57e7bec982c..e88c36fadf8 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -65,6 +65,10 @@ The height of the rack, measured in units. The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. +### Weight + +The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds). + ### Descending Units If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 897ee4ca356..22d56565e57 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -201,6 +201,7 @@ class RackSerializer(NetBoxModelSerializer): default=None) width = ChoiceField(choices=RackWidthChoices, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -208,8 +209,9 @@ class Meta: model = Rack fields = [ 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', + 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width', + 'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', + 'powerfeed_count', ] @@ -315,27 +317,29 @@ class DeviceTypeSerializer(NetBoxModelSerializer): ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType fields = [ 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', + 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', ] class ModuleTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') manufacturer = NestedManufacturerSerializer() + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) # module_count = serializers.IntegerField(read_only=True) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 7d35a40f9c6..8466d4861e9 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1314,6 +1314,24 @@ class CableLengthUnitChoices(ChoiceSet): ) +class WeightUnitChoices(ChoiceSet): + + # Metric + UNIT_KILOGRAM = 'kg' + UNIT_GRAM = 'g' + + # Imperial + UNIT_POUND = 'lb' + UNIT_OUNCE = 'oz' + + CHOICES = ( + (UNIT_KILOGRAM, 'Kilograms'), + (UNIT_GRAM, 'Grams'), + (UNIT_POUND, 'Pounds'), + (UNIT_OUNCE, 'Ounces'), + ) + + # # CableTerminations # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3a66e6c3063..a0c5e545c0e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -320,7 +320,7 @@ class Meta: model = Rack fields = [ 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', + 'outer_unit', 'weight', 'weight_unit' ] def search(self, queryset, name, value): @@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType fields = [ - 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ] def search(self, queryset, name, value): @@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): class Meta: model = ModuleType - fields = ['id', 'model', 'part_number'] + fields = ['id', 'model', 'part_number', 'weight', 'weight_unit'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 396f7e59bd6..d033d3a67f1 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -285,15 +285,26 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): widget=SmallTextarea, label='Comments' ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) model = Rack fieldsets = ( ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), ('Location', ('region', 'site_group', 'site', 'location')), ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), + ('Weight', ('weight', 'weight_unit')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'weight', 'weight_unit' ) @@ -355,12 +366,23 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) model = DeviceType fieldsets = ( - (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number', 'airflow') + nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit') class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -371,12 +393,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): part_number = forms.CharField( required=False ) + weight = forms.DecimalField( + min_value=0, + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) model = ModuleType fieldsets = ( - (None, ('manufacturer', 'part_number')), + ('Module Type', ('manufacturer', 'part_number')), + ('Weight', ('weight', 'weight_unit')), ) - nullable_fields = ('part_number',) + nullable_fields = ('part_number', 'weight', 'weight_unit') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -553,17 +586,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): 'type', 'status', 'tenant', 'label', 'color', 'length', ) - def clean(self): - super().clean() - - # Validate length/unit - length = self.cleaned_data.get('length') - length_unit = self.cleaned_data.get('length_unit') - if length and not length_unit: - raise forms.ValidationError({ - 'length_unit': "Must specify a unit when setting length" - }) - class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): domain = forms.CharField( diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 96b0d1319a3..818da83e1af 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -228,6 +228,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), + ('Weight', ('weight', 'weight_unit')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -281,6 +282,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte required=False ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class RackElevationFilterForm(RackFilterForm): @@ -370,6 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', )), + ('Weight', ('weight', 'weight_unit')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -465,6 +474,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ) ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class ModuleTypeFilterForm(NetBoxModelFilterSetForm): @@ -476,6 +492,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), + ('Weight', ('weight', 'weight_unit')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -529,6 +546,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): ) ) tag = TagFilterField(model) + weight = forms.DecimalField( + required=False + ) + weight_unit = forms.ChoiceField( + choices=add_blank_choice(WeightUnitChoices), + required=False + ) class DeviceRoleFilterForm(NetBoxModelFilterSetForm): diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index b33023ece64..4faefb623cd 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -260,7 +260,7 @@ class Meta: fields = [ 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'comments', 'tags', + 'outer_unit', 'weight', 'weight_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -273,6 +273,7 @@ class Meta: 'type': StaticSelect(), 'width': StaticSelect(), 'outer_unit': StaticSelect(), + 'weight_unit': StaticSelect(), } @@ -363,6 +364,7 @@ class DeviceTypeForm(NetBoxModelForm): ('Chassis', ( 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', )), + ('Attributes', ('weight', 'weight_unit')), ('Images', ('front_image', 'rear_image')), ) @@ -370,7 +372,7 @@ class Meta: model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'front_image', 'rear_image', 'comments', 'tags', + 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', ] widgets = { 'airflow': StaticSelect(), @@ -380,7 +382,8 @@ class Meta: }), 'rear_image': ClearableFileInput(attrs={ 'accept': DEVICETYPE_IMAGE_FORMATS - }) + }), + 'weight_unit': StaticSelect(), } @@ -392,16 +395,20 @@ class ModuleTypeForm(NetBoxModelForm): fieldsets = ( ('Module Type', ( - 'manufacturer', 'model', 'part_number', 'tags', + 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit' )), ) class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'comments', 'tags', + 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags', ] + widgets = { + 'weight_unit': StaticSelect(), + } + class DeviceRoleForm(NetBoxModelForm): slug = SlugField() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 52a98278a30..78cabbcd19e 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -211,6 +211,9 @@ def resolve_subdevice_role(self, info): def resolve_airflow(self, info): return self.airflow or None + def resolve_weight_unit(self, info): + return self.weight_unit or None + class FrontPortType(ComponentObjectType, CabledObjectMixin): @@ -328,6 +331,9 @@ class Meta: fields = '__all__' filterset_class = filtersets.ModuleTypeFilterSet + def resolve_weight_unit(self, info): + return self.weight_unit or None + class PlatformType(OrganizationalObjectType): @@ -416,6 +422,9 @@ def resolve_type(self, info): def resolve_outer_unit(self, info): return self.outer_unit or None + def resolve_weight_unit(self, info): + return self.weight_unit or None + class RackReservationType(NetBoxObjectType): diff --git a/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py new file mode 100644 index 00000000000..09bef573603 --- /dev/null +++ b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py @@ -0,0 +1,58 @@ +# Generated by Django 4.0.7 on 2022-09-23 01:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0162_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='moduletype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='rack', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='rack', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d0d9001adb5..b7c4abd3296 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,7 +1,8 @@ import decimal - import yaml +from functools import cached_property + from django.apps import apps from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError @@ -21,6 +22,7 @@ from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * +from .mixins import WeightMixin __all__ = ( @@ -71,7 +73,7 @@ def get_absolute_url(self): return reverse('dcim:manufacturer', args=[self.pk]) -class DeviceType(NetBoxModel): +class DeviceType(NetBoxModel, WeightMixin): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -139,7 +141,7 @@ class DeviceType(NetBoxModel): ) clone_fields = ( - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', ) class Meta: @@ -315,7 +317,7 @@ def is_child_device(self): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(NetBoxModel): +class ModuleType(NetBoxModel, WeightMixin): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a @@ -344,7 +346,7 @@ class ModuleType(NetBoxModel): to='extras.ImageAttachment' ) - clone_fields = ('manufacturer',) + clone_fields = ('manufacturer', 'weight', 'weight_unit',) class Meta: ordering = ('manufacturer', 'model') @@ -946,6 +948,18 @@ def get_children(self): def get_status_color(self): return DeviceStatusChoices.colors.get(self.status) + @cached_property + def total_weight(self): + total_weight = sum( + module.module_type._abs_weight + for module in Module.objects.filter(device=self) + .exclude(module_type___abs_weight__isnull=True) + .prefetch_related('module_type') + ) + if self.device_type._abs_weight: + total_weight += self.device_type._abs_weight + return round(total_weight / 1000, 2) + class Module(NetBoxModel, ConfigContextModel): """ diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py new file mode 100644 index 00000000000..b5449332b5d --- /dev/null +++ b/netbox/dcim/models/mixins.py @@ -0,0 +1,45 @@ +from django.core.exceptions import ValidationError +from django.db import models +from dcim.choices import * +from utilities.utils import to_grams + + +class WeightMixin(models.Model): + weight = models.DecimalField( + max_digits=8, + decimal_places=2, + blank=True, + null=True + ) + weight_unit = models.CharField( + max_length=50, + choices=WeightUnitChoices, + blank=True, + ) + # Stores the normalized weight (in grams) for database ordering + _abs_weight = models.PositiveBigIntegerField( + blank=True, + null=True + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + + # Store the given weight (if any) in grams for use in database ordering + if self.weight and self.weight_unit: + self._abs_weight = to_grams(self.weight, self.weight_unit) + else: + self._abs_weight = None + + super().save(*args, **kwargs) + + def clean(self): + super().clean() + + # Validate weight and weight_unit + if self.weight is not None and not self.weight_unit: + raise ValidationError("Must specify a unit when setting a weight") + elif self.weight is None: + self.weight_unit = '' diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 10550e9063a..6da48b65ca8 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,5 @@ import decimal +from functools import cached_property from django.apps import apps from django.contrib.auth.models import User @@ -18,7 +19,8 @@ from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string, drange from .device_components import PowerPort -from .devices import Device +from .devices import Device, Module +from .mixins import WeightMixin from .power import PowerFeed __all__ = ( @@ -62,7 +64,7 @@ def get_absolute_url(self): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(NetBoxModel): +class Rack(NetBoxModel, WeightMixin): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -185,7 +187,7 @@ class Rack(NetBoxModel): clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', + 'outer_depth', 'outer_unit', 'weight', 'weight_unit', ) class Meta: @@ -454,6 +456,22 @@ def get_power_utilization(self): return int(allocated_draw / available_power_total * 100) + @cached_property + def total_weight(self): + total_weight = sum( + device.device_type._abs_weight + for device in self.devices.exclude(device_type___abs_weight__isnull=True).prefetch_related('device_type') + ) + total_weight += sum( + module.module_type._abs_weight + for module in Module.objects.filter(device__rack=self) + .exclude(module_type___abs_weight__isnull=True) + .prefetch_related('module_type') + ) + if self._abs_weight: + total_weight += self._abs_weight + return round(total_weight / 1000, 2) + class RackReservation(NetBoxModel): """ diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3ed4d8c080a..8f371ef1a5d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -5,7 +5,7 @@ InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from netbox.tables import NetBoxTable, columns -from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS +from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT __all__ = ( 'ConsolePortTemplateTable', @@ -85,12 +85,16 @@ class DeviceTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:devicetype_list' ) + weight = columns.TemplateColumn( + template_code=DEVICE_WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'airflow', 'weight', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index e40d7bd80a0..b644e6ba66d 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -2,6 +2,7 @@ from dcim.models import Module, ModuleType from netbox.tables import NetBoxTable, columns +from .template_code import DEVICE_WEIGHT __all__ = ( 'ModuleTable', @@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) + weight = columns.TemplateColumn( + template_code=DEVICE_WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 39553bac066..ffca071456e 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -4,6 +4,7 @@ from dcim.models import Rack, RackReservation, RackRole from netbox.tables import NetBoxTable, columns from tenancy.tables import TenancyColumnsMixin +from .template_code import DEVICE_WEIGHT __all__ = ( 'RackTable', @@ -82,13 +83,17 @@ class RackTable(TenancyColumnsMixin, NetBoxTable): template_code="{{ record.outer_depth }} {{ record.outer_unit }}", verbose_name='Outer Depth' ) + weight = columns.TemplateColumn( + template_code=DEVICE_WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) class Meta(NetBoxTable.Meta): model = Rack fields = ( - 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', - 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', - 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', + 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments', + 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index dfc77b85418..9b8fb8fd629 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -15,6 +15,11 @@ {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} """ +DEVICE_WEIGHT = """ +{% load helpers %} +{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %} +""" + DEVICE_LINK = """ {{ record.name|default:'Unnamed device' }} diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7a745721b0c..d4922fb1de3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -409,9 +409,9 @@ def setUpTestData(cls): Tenant.objects.bulk_create(tenants) racks = ( - Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), + Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) Rack.objects.bulk_create(racks) @@ -517,6 +517,14 @@ def test_tenant_group(self): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() @@ -688,9 +696,9 @@ def setUpTestData(cls): Manufacturer.objects.bulk_create(manufacturers) device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), - DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), - DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) DeviceType.objects.bulk_create(device_types) @@ -839,6 +847,14 @@ def test_inventory_items(self): params = {'inventory_items': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() @@ -855,9 +871,9 @@ def setUpTestData(cls): Manufacturer.objects.bulk_create(manufacturers) module_types = ( - ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'), - ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'), - ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'), + ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND), + ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND), + ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM), ) ModuleType.objects.bulk_create(module_types) @@ -943,6 +959,14 @@ def test_pass_through_ports(self): params = {'pass_through_ports': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_weight(self): + params = {'weight': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_weight_unit(self): + params = {'weight_unit': WeightUnitChoices.UNIT_POUND} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index bb3ec9d2e51..6a37a8d065a 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -35,6 +35,16 @@
Full Depth {% checkmark object.is_full_depth %}
Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Parent/Child diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 2c8e77be39d..8128e64be24 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -22,6 +22,16 @@
Module Type
Part Number {{ object.part_number|placeholder }}
Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Instances {{ instance_count }}
@@ -147,6 +145,20 @@
{% endif %}
+ + + + + + + +
Rack Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Total Weight{{ object.total_weight|floatformat }} Kilograms
@@ -186,6 +198,7 @@
{% endif %} + {% include 'inc/panels/image_attachments.html' %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index ca97be34d46..4a340c147fa 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -57,6 +57,14 @@
Dimensions
{% render_field form.desc_units %} +
+
+
Weight
+
+ {% render_field form.weight %} + {% render_field form.weight_unit %} +
+ {% if form.custom_fields %}
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 69ab615fcbb..9f587e88d23 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -12,7 +12,7 @@ from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel -from dcim.choices import CableLengthUnitChoices +from dcim.choices import CableLengthUnitChoices, WeightUnitChoices from extras.plugins import PluginConfig from extras.utils import is_taggable from netbox.config import get_config @@ -270,6 +270,31 @@ def to_meters(length, unit): raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.") +def to_grams(weight, unit): + """ + Convert the given weight to kilograms. + """ + try: + if weight < 0: + raise ValueError("Weight must be a positive number") + except TypeError: + raise TypeError(f"Invalid value '{weight}' for weight (must be a number)") + + valid_units = WeightUnitChoices.values() + if unit not in valid_units: + raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}") + + if unit == WeightUnitChoices.UNIT_KILOGRAM: + return weight * 1000 + if unit == WeightUnitChoices.UNIT_GRAM: + return weight + if unit == WeightUnitChoices.UNIT_POUND: + return weight * Decimal(453.592) + if unit == WeightUnitChoices.UNIT_OUNCE: + return weight * Decimal(28.3495) + raise ValueError(f"Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.") + + def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. From 97d561ac330db53c902717fddb52b9209db598bf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 16:37:07 -0400 Subject: [PATCH 035/179] Changelog for #9654, #10348 --- docs/release-notes/version-3.4.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 24e5a0ea9b6..4019ef47447 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -18,7 +18,9 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Enhancements * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive +* [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups +* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type ### Plugins API @@ -35,5 +37,11 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * circuits.provider * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields +* dcim.DeviceType + * Added optional `weight` and `weight_unit` fields +* dcim.ModuleType + * Added optional `weight` and `weight_unit` fields +* dcim.Rack + * Added optional `weight` and `weight_unit` fields * ipam.FHRPGroup * Added optional `name` field From 5cc55d1e993d4d21655a7f536dd1ce299755784b Mon Sep 17 00:00:00 2001 From: Patrick Hurrelmann Date: Tue, 27 Sep 2022 17:24:19 +0200 Subject: [PATCH 036/179] Fixes: #10465 Format all remaining displayed rackunits with floatformat (#10481) * Fixes: #10465 Try to finish #10268 and format all remaining displayed rackunits with floatformat * #10465: PEP8 fix Co-authored-by: Patrick Hurrelmann Co-authored-by: jeremystretch --- netbox/dcim/tables/devicetypes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index bc596a297dd..c48e93ca7e3 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -92,6 +92,9 @@ class DeviceTypeTable(NetBoxTable): template_code=DEVICE_WEIGHT, order_by=('_abs_weight', 'weight_unit') ) + u_height = columns.TemplateColumn( + template_code='{{ value|floatformat }}' + ) class Meta(NetBoxTable.Meta): model = DeviceType From ac7db3cc88dcb8159df3256c64b5f8dda642050e Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Tue, 20 Sep 2022 15:50:33 -0400 Subject: [PATCH 037/179] Tidy-up imports and typing (cherry picked from commit adee5cf6a8856ceda0170a4382cec8fd784be93b) --- netbox/netbox/settings.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cfd4d231c91..a0e8f5ffab5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,18 +1,17 @@ import hashlib import importlib -import logging import os import platform -import re -import socket import sys import warnings from urllib.parse import urlsplit +import django import sentry_sdk from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from django.utils.encoding import force_str from sentry_sdk.integrations.django import DjangoIntegration from netbox.config import PARAMS @@ -20,9 +19,7 @@ # Monkey patch to fix Django 4.0 support for graphene-django (see # https://github.com/graphql-python/graphene-django/issues/1284) # TODO: Remove this when graphene-django 2.16 becomes available -import django -from django.utils.encoding import force_str -django.utils.encoding.force_text = force_str +django.utils.encoding.force_text = force_str # type: ignore # @@ -186,7 +183,7 @@ if STORAGE_BACKEND.startswith('storages.'): try: - import storages.utils + import storages.utils # type: ignore except ModuleNotFoundError as e: if getattr(e, 'name') == 'storages': raise ImproperlyConfigured( From dc522a0135df41f0905c114ad40ffc5026287f47 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Tue, 20 Sep 2022 17:55:44 -0400 Subject: [PATCH 038/179] Initial implementation - Allows to specify a list of django-apps to be "installed" alongside the plugin. (cherry picked from commit 6c7296200d756d2acbba3a589a7759f3a690cc48) --- netbox/extras/plugins/__init__.py | 3 +++ netbox/netbox/settings.py | 34 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index ef1106aeac3..3efa9aaa74d 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -55,6 +55,9 @@ class PluginConfig(AppConfig): # Django-rq queues dedicated to the plugin queues = [] + # Django apps to append to INSTALLED_APPS when plugin requires them. + django_apps = [] + # Default integration paths. Plugin authors can override these to customize the paths to # integrated components. graphql_schema = 'graphql.schema' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a0e8f5ffab5..ed225da5276 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,5 +1,6 @@ import hashlib import importlib +import importlib.util import os import platform import sys @@ -12,6 +13,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator from django.utils.encoding import force_str +from extras.plugins import PluginConfig from sentry_sdk.integrations.django import DjangoIntegration from netbox.config import PARAMS @@ -660,14 +662,42 @@ def _setting(name, default=None): # Determine plugin config and add to INSTALLED_APPS. try: - plugin_config = plugin.config - INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__)) + plugin_config: PluginConfig = plugin.config except AttributeError: raise ImproperlyConfigured( "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file " "and point to the PluginConfig subclass.".format(plugin_name) ) + plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore + # Gather additionnal apps to load alongside this plugin + plugin_apps = plugin_config.django_apps + if plugin_name in plugin_apps: + plugin_apps.pop(plugin_name) + if plugin_module not in plugin_apps: + plugin_apps.append(plugin_module) + + # Test if we can import all modules (or its parent, for PluginConfigs and AppConfigs) + for app in plugin_apps: + if "." in app: + parts = app.split(".") + spec = importlib.util.find_spec(".".join(parts[:-1])) + else: + spec = importlib.util.find_spec(app) + if spec is None: + raise ImproperlyConfigured( + f"Plugin {plugin_name} provides a 'config' variable which contains invalid 'plugin_apps'. " + f"The module {app}, from this list, cannot be imported. Check that the additionnal app has been " + "installed within the correct Python environment." + ) + + + INSTALLED_APPS.extend(plugin_apps) + + # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence + sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) + INSTALLED_APPS = list(sorted_apps) + # Validate user-provided configuration settings and assign defaults if plugin_name not in PLUGINS_CONFIG: PLUGINS_CONFIG[plugin_name] = {} From 5c1417c4c76cd09942695fcf71abc37d06689962 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 28 Sep 2022 18:11:10 -0400 Subject: [PATCH 039/179] PEP8 fixes --- netbox/netbox/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ed225da5276..0af5eaa1b9b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -21,7 +21,7 @@ # Monkey patch to fix Django 4.0 support for graphene-django (see # https://github.com/graphql-python/graphene-django/issues/1284) # TODO: Remove this when graphene-django 2.16 becomes available -django.utils.encoding.force_text = force_str # type: ignore +django.utils.encoding.force_text = force_str # type: ignore # @@ -669,7 +669,8 @@ def _setting(name, default=None): "and point to the PluginConfig subclass.".format(plugin_name) ) - plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore + plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore + # Gather additionnal apps to load alongside this plugin plugin_apps = plugin_config.django_apps if plugin_name in plugin_apps: @@ -691,7 +692,6 @@ def _setting(name, default=None): "installed within the correct Python environment." ) - INSTALLED_APPS.extend(plugin_apps) # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence From d4a7af8a896f9962a252af43863f559c5340c1d2 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 29 Sep 2022 17:12:18 -0400 Subject: [PATCH 040/179] Update plugins development docs --- docs/plugins/development/index.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 98db9e0bb2d..7c23347135c 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -14,6 +14,7 @@ Plugins can do a lot, including: * Provide their own "pages" (views) in the web user interface * Inject template content and navigation links * Extend NetBox's REST and GraphQL APIs +* Load additionnal Django Apps * Add custom request/response middleware However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. @@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig): default_settings = { 'baz': True } + django_apps = ["foo", "bar", "baz"] config = FooBarConfig ``` @@ -101,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `required_settings` | A list of any configuration parameters that **must** be defined by the user | | `default_settings` | A dictionary of configuration parameters and their default values | +| `django_apps` | A list of additionnal apps to load alongside the plugin | | `min_version` | Minimum version of NetBox with which the plugin is compatible | | `max_version` | Maximum version of NetBox with which the plugin is compatible | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | @@ -112,6 +115,15 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. +#### Important notes about `django_apps` + +Loading additional apps may cause more harm than good and could lead to make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intented to be used only for advanced use-cases that require a deeper Django integration. + +Apps from this list are inserted *before* the plugin's `PluginConfig` in the same order. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin. + +Any additionnal app must be installed within the the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. + + ## Create setup.py `setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: From 0607295081b0f56bcfb3b6ab60925446b95baeaf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 16:49:49 -0400 Subject: [PATCH 041/179] Docs cleanup --- docs/plugins/development/index.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 7c23347135c..d5aea0591a0 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -14,7 +14,7 @@ Plugins can do a lot, including: * Provide their own "pages" (views) in the web user interface * Inject template content and navigation links * Extend NetBox's REST and GraphQL APIs -* Load additionnal Django Apps +* Load additional Django apps * Add custom request/response middleware However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. @@ -103,7 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `required_settings` | A list of any configuration parameters that **must** be defined by the user | | `default_settings` | A dictionary of configuration parameters and their default values | -| `django_apps` | A list of additionnal apps to load alongside the plugin | +| `django_apps` | A list of additional Django apps to load alongside the plugin | | `min_version` | Minimum version of NetBox with which the plugin is compatible | | `max_version` | Maximum version of NetBox with which the plugin is compatible | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | @@ -115,14 +115,13 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. -#### Important notes about `django_apps` +#### Important Notes About `django_apps` -Loading additional apps may cause more harm than good and could lead to make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intented to be used only for advanced use-cases that require a deeper Django integration. +Loading additional apps may cause more harm than good and could make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intended only for advanced use cases that require a deeper Django integration. -Apps from this list are inserted *before* the plugin's `PluginConfig` in the same order. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin. - -Any additionnal app must be installed within the the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. +Apps from this list are inserted *before* the plugin's `PluginConfig` in the order defined. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin. +Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin. ## Create setup.py From f7860138c79402c30364254ff747c87d743a311b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 17:01:37 -0400 Subject: [PATCH 042/179] Rename plugin_apps to django_apps for clarity --- netbox/netbox/settings.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0af5eaa1b9b..a2a6f57a668 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -671,15 +671,15 @@ def _setting(name, default=None): plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore - # Gather additionnal apps to load alongside this plugin - plugin_apps = plugin_config.django_apps - if plugin_name in plugin_apps: - plugin_apps.pop(plugin_name) - if plugin_module not in plugin_apps: - plugin_apps.append(plugin_module) + # Gather additional apps to load alongside this plugin + django_apps = plugin_config.django_apps + if plugin_name in django_apps: + django_apps.pop(plugin_name) + if plugin_module not in django_apps: + django_apps.append(plugin_module) # Test if we can import all modules (or its parent, for PluginConfigs and AppConfigs) - for app in plugin_apps: + for app in django_apps: if "." in app: parts = app.split(".") spec = importlib.util.find_spec(".".join(parts[:-1])) @@ -687,12 +687,12 @@ def _setting(name, default=None): spec = importlib.util.find_spec(app) if spec is None: raise ImproperlyConfigured( - f"Plugin {plugin_name} provides a 'config' variable which contains invalid 'plugin_apps'. " - f"The module {app}, from this list, cannot be imported. Check that the additionnal app has been " + f"Failed to load django_apps specified by plugin {plugin_name}: {django_apps} " + f"The module {app} cannot be imported. Check that the necessary package has been " "installed within the correct Python environment." ) - INSTALLED_APPS.extend(plugin_apps) + INSTALLED_APPS.extend(django_apps) # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) From 568e0c7ff66b149583e96947bc500374579c6fee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 30 Sep 2022 17:30:18 -0400 Subject: [PATCH 043/179] Changelog for #9880 --- docs/release-notes/version-3.4.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 4019ef47447..537a4968d02 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -25,6 +25,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Plugins API * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus +* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin ### Other Changes From 81d99a00611f587882b5c1728fcc945b87421ba8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 4 Oct 2022 10:50:34 -0700 Subject: [PATCH 044/179] #10556 add display to GraphQL --- netbox/netbox/graphql/types.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 7d1b26f8472..41eff6d46e6 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,3 +1,5 @@ +import graphene + from django.contrib.contenttypes.models import ContentType from graphene_django import DjangoObjectType @@ -19,6 +21,11 @@ class BaseObjectType(DjangoObjectType): """ Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions. """ + display = graphene.String() + + def resolve_display(parent, info, **kwargs): + return str(parent) + class Meta: abstract = True From 24ba840be7343404c7514b78c89c694f8cf97413 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 5 Oct 2022 12:50:17 -0700 Subject: [PATCH 045/179] 10472 graphene 3 (#10473) * update to Graphene 3.0.0 * 10472 exempt view permissions on tests * 10472 exempt permission check on graphql tests --- netbox/netbox/graphql/scalars.py | 2 +- netbox/netbox/graphql/schema.py | 2 +- netbox/utilities/testing/api.py | 2 ++ requirements.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/graphql/scalars.py b/netbox/netbox/graphql/scalars.py index 7d14189dd95..8fc186b4dec 100644 --- a/netbox/netbox/graphql/scalars.py +++ b/netbox/netbox/graphql/scalars.py @@ -1,6 +1,6 @@ from graphene import Scalar from graphql.language import ast -from graphql.type.scalars import MAX_INT, MIN_INT +from graphene.types.scalars import MAX_INT, MIN_INT class BigInt(Scalar): diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index f0bc8559c03..084ac3607eb 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -12,12 +12,12 @@ class Query( + UsersQuery, CircuitsQuery, DCIMQuery, ExtrasQuery, IPAMQuery, TenancyQuery, - UsersQuery, VirtualizationQuery, WirelessQuery, *registry['plugins']['graphql_schemas'], # Append plugin schemas diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index b3fa5704f84..f26e5fffc32 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -467,6 +467,7 @@ def _build_query(self, name, **filters): return query @override_settings(LOGIN_REQUIRED=True) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user']) def test_graphql_get_object(self): url = reverse('graphql') field_name = self._get_graphql_base_name() @@ -492,6 +493,7 @@ def test_graphql_get_object(self): self.assertNotIn('errors', data) @override_settings(LOGIN_REQUIRED=True) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user']) def test_graphql_list_objects(self): url = reverse('graphql') field_name = f'{self._get_graphql_base_name()}_list' diff --git a/requirements.txt b/requirements.txt index 16c55462f47..5b2a1141ff0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ django-taggit==3.0.0 django-timezone-field==5.0 djangorestframework==3.14.0 drf-yasg[validation]==1.21.4 -graphene-django==2.15.0 +graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 From 664d5db5eb2bde7555aab3d19e4aaf82054c9ea8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Oct 2022 15:53:50 -0400 Subject: [PATCH 046/179] Changelog for #10556 --- docs/release-notes/version-3.4.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 537a4968d02..313a84f2003 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -21,6 +21,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type +* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types ### Plugins API @@ -46,3 +47,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a * Added optional `weight` and `weight_unit` fields * ipam.FHRPGroup * Added optional `name` field + +### GraphQL API Changes + +* All object types now include a `display` field From 0d7851ed9de2792ea6d9ed223c315c235290ddd7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Oct 2022 16:20:35 -0400 Subject: [PATCH 047/179] #9072: Implement a mechanism for dynamically registering model detail views --- netbox/extras/registry.py | 1 + netbox/netbox/models/features.py | 22 ++++++++ netbox/templates/generic/object.html | 30 ++--------- .../templates/tabs/model_view_tabs.html | 8 +++ netbox/utilities/templatetags/tabs.py | 50 +++++++++++++++++++ netbox/utilities/urls.py | 35 +++++++++++++ netbox/utilities/views.py | 38 ++++++++++++++ 7 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 netbox/utilities/templates/tabs/model_view_tabs.html create mode 100644 netbox/utilities/templatetags/tabs.py create mode 100644 netbox/utilities/urls.py diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index e1437c00e87..b748b6f9083 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -29,3 +29,4 @@ def __delitem__(self, key): feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES } registry['denormalized_fields'] = collections.defaultdict(list) +registry['views'] = collections.defaultdict(dict) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ce80cec3e72..0d519a8baf2 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -13,6 +13,7 @@ from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder from utilities.utils import serialize_object +from utilities.views import register_model_view __all__ = ( 'ChangeLoggingMixin', @@ -292,3 +293,24 @@ def _register_features(sender, **kwargs): feature for feature, cls in FEATURES_MAP if issubclass(sender, cls) } register_features(sender, features) + + # Feature view registration + if issubclass(sender, JournalingMixin): + register_model_view( + sender, + 'journal', + 'netbox.views.generic.ObjectJournalView', + tab_label='Journal', + tab_badge=lambda x: x.journal_entries.count(), + tab_permission='extras.view_journalentry', + kwargs={'model': sender} + ) + if issubclass(sender, ChangeLoggingMixin): + register_model_view( + sender, + 'changelog', + 'netbox.views.generic.ObjectChangeLogView', + tab_label='Changelog', + tab_permission='extras.view_objectchange', + kwargs={'model': sender} + ) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index ef95ccdc01d..2c3c76329d3 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -4,6 +4,7 @@ {% load helpers %} {% load perms %} {% load plugins %} +{% load tabs %} {% comment %} Blocks: @@ -83,34 +84,11 @@ {{ object|meta:"verbose_name"|bettertitle }} - {# Include any additional tabs #} + {# Include any extra tabs passed by the view #} {% block extra_tabs %}{% endblock %} - {# Object journal #} - {% if perms.extras.view_journalentry %} - {% with journal_viewname=object|viewname:'journal' %} - {% url journal_viewname pk=object.pk as journal_url %} - {% if journal_url %} - - {% endif %} - {% endwith %} - {% endif %} - - {# Object changelog #} - {% if perms.extras.view_objectchange %} - {% with changelog_viewname=object|viewname:'changelog' %} - {% url changelog_viewname pk=object.pk as changelog_url %} - {% if changelog_url %} - - {% endif %} - {% endwith %} - {% endif %} + {# Include tabs for registered model views #} + {% model_view_tabs object %} {% endblock tabs %} diff --git a/netbox/utilities/templates/tabs/model_view_tabs.html b/netbox/utilities/templates/tabs/model_view_tabs.html new file mode 100644 index 00000000000..2c6a9046d94 --- /dev/null +++ b/netbox/utilities/templates/tabs/model_view_tabs.html @@ -0,0 +1,8 @@ +{% for tab in tabs %} + +{% endfor %} diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py new file mode 100644 index 00000000000..13b4a5f632c --- /dev/null +++ b/netbox/utilities/templatetags/tabs.py @@ -0,0 +1,50 @@ +from django import template +from django.core.exceptions import ImproperlyConfigured +from django.urls import reverse + +from extras.registry import registry + +register = template.Library() + + +# +# Object detail view tabs +# + +@register.inclusion_tag('tabs/model_view_tabs.html', takes_context=True) +def model_view_tabs(context, instance): + app_label = instance._meta.app_label + model_name = instance._meta.model_name + user = context['request'].user + tabs = [] + + # Retrieve registered views for this model + try: + views = registry['views'][app_label][model_name] + except KeyError: + # No views have been registered for this model + views = [] + + # Compile a list of tabs to be displayed in the UI + for view in views: + if view['tab_label'] and (not view['tab_permission'] or user.has_perm(view['tab_permission'])): + + # Determine the value of the tab's badge (if any) + if view['tab_badge'] and callable(view['tab_badge']): + badge_value = view['tab_badge'](instance) + elif view['tab_badge']: + badge_value = view['tab_badge'] + else: + badge_value = None + + tabs.append({ + 'name': view['name'], + 'url': reverse(f"{app_label}:{model_name}_{view['name']}", args=[instance.pk]), + 'label': view['tab_label'], + 'badge_value': badge_value, + 'is_active': context.get('active_tab') == view['name'], + }) + + return { + 'tabs': tabs, + } diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py new file mode 100644 index 00000000000..3920889b33c --- /dev/null +++ b/netbox/utilities/urls.py @@ -0,0 +1,35 @@ +from django.urls import path +from django.utils.module_loading import import_string +from django.views.generic import View + +from extras.registry import registry + + +def get_model_urls(app_label, model_name): + """ + Return a list of URL paths for detail views registered to the given model. + + Args: + app_label: App/plugin name + model_name: Model name + """ + paths = [] + + # Retrieve registered views for this model + try: + views = registry['views'][app_label][model_name] + except KeyError: + # No views have been registered for this model + views = [] + + for view in views: + # Import the view class or function + callable = import_string(view['path']) + if issubclass(callable, View): + callable = callable.as_view() + # Create a path to the view + paths.append( + path(f"{view['name']}/", callable, name=f"{model_name}_{view['name']}", kwargs=view['kwargs']) + ) + + return paths diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 858e7b4913f..a4f5c79a9af 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -3,8 +3,16 @@ from django.urls import reverse from django.urls.exceptions import NoReverseMatch +from extras.registry import registry from .permissions import resolve_permission +__all__ = ( + 'ContentTypePermissionRequiredMixin', + 'GetReturnURLMixin', + 'ObjectPermissionRequiredMixin', + 'register_model_view', +) + # # View Mixins @@ -122,3 +130,33 @@ def get_return_url(self, request, obj=None): # If all else fails, return home. Ideally this should never happen. return reverse('home') + + +def register_model_view(model, name, view_path, tab_label=None, tab_badge=None, tab_permission=None, kwargs=None): + """ + Register a subview for a core model. + + Args: + model: The Django model class with which this view will be associated + name: The name to register when creating a URL path + view_path: A dotted path to the view class or function (e.g. 'myplugin.views.FooView') + tab_label: The label to display for the view's tab under the model view (optional) + tab_badge: A static value or callable to display a badge within the view's tab (optional). If a callable is + specified, it must accept the current object as its single positional argument. + tab_permission: The name of the permission required to display the tab (optional) + kwargs: A dictionary of keyword arguments to send to the view (optional) + """ + app_label = model._meta.app_label + model_name = model._meta.model_name + + if model_name not in registry['views'][app_label]: + registry['views'][app_label][model_name] = [] + + registry['views'][app_label][model_name].append({ + 'name': name, + 'path': view_path, + 'tab_label': tab_label, + 'tab_badge': tab_badge, + 'tab_permission': tab_permission, + 'kwargs': kwargs or {}, + }) From a0bae06ff7fb6071dc90acdc6035f0897b94b194 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Oct 2022 16:21:23 -0400 Subject: [PATCH 048/179] Replace static journaling, changelog URL paths with dynamic resolution --- netbox/circuits/urls.py | 17 ++++---- netbox/dcim/urls.py | 79 ++++++++++++++++------------------- netbox/extras/urls.py | 27 +++++------- netbox/ipam/urls.py | 51 +++++++++------------- netbox/tenancy/urls.py | 17 ++++---- netbox/virtualization/urls.py | 17 ++++---- netbox/wireless/urls.py | 13 +++--- 7 files changed, 90 insertions(+), 131 deletions(-) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 5b15b29aca8..55ceeddc325 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,9 +1,9 @@ -from django.urls import path +from django.urls import include, path from dcim.views import PathTraceView -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * +from .models import CircuitTermination app_name = 'circuits' urlpatterns = [ @@ -17,8 +17,7 @@ path('providers//', views.ProviderView.as_view(), name='provider'), path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), - path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), + path('providers//', include(get_model_urls('circuits', 'provider'))), # Provider networks path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'), @@ -29,8 +28,7 @@ path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'), path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'), path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'), - path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}), - path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}), + path('provider-networks//', include(get_model_urls('circuits', 'providernetwork'))), # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), @@ -41,7 +39,7 @@ path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), - path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types//', include(get_model_urls('circuits', 'circuittype'))), # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), @@ -52,9 +50,8 @@ path('circuits//', views.CircuitView.as_view(), name='circuit'), path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), - path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path('circuits//journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}), path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), + path('circuits//', include(get_model_urls('circuits', 'circuit'))), # Circuit terminations path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c11a92a99dd..86d28e22460 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,8 +1,10 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * +from .models import ( + ConsolePort, ConsoleServerPort, FrontPort, Interface, PowerFeed, PowerPort, PowerOutlet, RearPort, +) app_name = 'dcim' urlpatterns = [ @@ -16,7 +18,7 @@ path('regions//', views.RegionView.as_view(), name='region'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), - path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + path('regions//', include(get_model_urls('dcim', 'region'))), # Site groups path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'), @@ -27,7 +29,7 @@ path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'), path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), - path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), + path('site-groups//', include(get_model_urls('dcim', 'sitegroup'))), # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), @@ -38,8 +40,7 @@ path('sites//', views.SiteView.as_view(), name='site'), path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), - path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), + path('sites//', include(get_model_urls('dcim', 'site'))), # Locations path('locations/', views.LocationListView.as_view(), name='location_list'), @@ -50,7 +51,7 @@ path('locations//', views.LocationView.as_view(), name='location'), path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), - path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), + path('locations//', include(get_model_urls('dcim', 'location'))), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), @@ -61,7 +62,7 @@ path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), - path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), + path('rack-roles//', include(get_model_urls('dcim', 'rackrole'))), # Rack reservations path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), @@ -72,8 +73,7 @@ path('rack-reservations//', views.RackReservationView.as_view(), name='rackreservation'), path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), - path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), - path('rack-reservations//journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}), + path('rack-reservations//', include(get_model_urls('dcim', 'rackreservation'))), # Racks path('racks/', views.RackListView.as_view(), name='rack_list'), @@ -85,8 +85,7 @@ path('racks//', views.RackView.as_view(), name='rack'), path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'), path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), - path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), - path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), + path('racks//', include(get_model_urls('dcim', 'rack'))), # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), @@ -97,7 +96,7 @@ path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), - path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers//', include(get_model_urls('dcim', 'manufacturer'))), # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -118,8 +117,7 @@ path('device-types//inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), - path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), - path('device-types//journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}), + path('device-types//', include(get_model_urls('dcim', 'devicetype'))), # Module types path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'), @@ -137,8 +135,7 @@ path('module-types//rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'), path('module-types//edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'), path('module-types//delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'), - path('module-types//changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}), - path('module-types//journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}), + path('module-types//', include(get_model_urls('dcim', 'moduletype'))), # Console port templates path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), @@ -229,7 +226,7 @@ path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), - path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles//', include(get_model_urls('dcim', 'devicerole'))), # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), @@ -240,7 +237,7 @@ path('platforms//', views.PlatformView.as_view(), name='platform'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), - path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms//', include(get_model_urls('dcim', 'platform'))), # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), @@ -264,11 +261,10 @@ path('devices//device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'), path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path('devices//journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + path('devices//', include(get_model_urls('dcim', 'device'))), # Modules path('modules/', views.ModuleListView.as_view(), name='module_list'), @@ -279,8 +275,7 @@ path('modules//', views.ModuleView.as_view(), name='module'), path('modules//edit/', views.ModuleEditView.as_view(), name='module_edit'), path('modules//delete/', views.ModuleDeleteView.as_view(), name='module_delete'), - path('modules//changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}), - path('modules//journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}), + path('modules//', include(get_model_urls('dcim', 'module'))), # Console ports path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), @@ -293,8 +288,8 @@ path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'), path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), - path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('console-ports//', include(get_model_urls('dcim', 'consoleport'))), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports @@ -308,8 +303,8 @@ path('console-server-ports//', views.ConsoleServerPortView.as_view(), name='consoleserverport'), path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), - path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('console-server-ports//', include(get_model_urls('dcim', 'consoleserverport'))), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports @@ -323,8 +318,8 @@ path('power-ports//', views.PowerPortView.as_view(), name='powerport'), path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), - path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('power-ports//', include(get_model_urls('dcim', 'powerport'))), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets @@ -338,8 +333,8 @@ path('power-outlets//', views.PowerOutletView.as_view(), name='poweroutlet'), path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), - path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('power-outlets//', include(get_model_urls('dcim', 'poweroutlet'))), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces @@ -353,8 +348,8 @@ path('interfaces//', views.InterfaceView.as_view(), name='interface'), path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), - path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('interfaces//', include(get_model_urls('dcim', 'interface'))), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports @@ -368,8 +363,8 @@ path('front-ports//', views.FrontPortView.as_view(), name='frontport'), path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), - path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path('front-ports//', include(get_model_urls('dcim', 'frontport'))), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports @@ -383,8 +378,8 @@ path('rear-ports//', views.RearPortView.as_view(), name='rearport'), path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), - path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path('rear-ports//', include(get_model_urls('dcim', 'rearport'))), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Module bays @@ -397,7 +392,7 @@ path('module-bays//', views.ModuleBayView.as_view(), name='modulebay'), path('module-bays//edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'), path('module-bays//delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'), - path('module-bays//changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}), + path('module-bays//', include(get_model_urls('dcim', 'modulebay'))), path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'), # Device bays @@ -410,9 +405,9 @@ path('device-bays//', views.DeviceBayView.as_view(), name='devicebay'), path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - path('device-bays//changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}), path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), + path('device-bays//', include(get_model_urls('dcim', 'devicebay'))), path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), # Inventory items @@ -425,10 +420,10 @@ path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'), path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), - path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}), + path('inventory-items//', include(get_model_urls('dcim', 'inventoryitem'))), path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'), - # Device roles + # Inventory item roles path('inventory-item-roles/', views.InventoryItemRoleListView.as_view(), name='inventoryitemrole_list'), path('inventory-item-roles/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'), path('inventory-item-roles/import/', views.InventoryItemRoleBulkImportView.as_view(), name='inventoryitemrole_import'), @@ -437,7 +432,7 @@ path('inventory-item-roles//', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'), path('inventory-item-roles//edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'), path('inventory-item-roles//delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'), - path('inventory-item-roles//changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}), + path('inventory-item-roles//', include(get_model_urls('dcim', 'inventoryitemrole'))), # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), @@ -448,8 +443,7 @@ path('cables//', views.CableView.as_view(), name='cable'), path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), - path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), - path('cables//journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}), + path('cables//', include(get_model_urls('dcim', 'cable'))), # Console/power/interface connections (read-only) path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), @@ -465,9 +459,8 @@ path('virtual-chassis//', views.VirtualChassisView.as_view(), name='virtualchassis'), path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), - path('virtual-chassis//journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}), path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + path('virtual-chassis//', include(get_model_urls('dcim', 'virtualchassis'))), path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), # Power panels @@ -479,8 +472,7 @@ path('power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), - path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), - path('power-panels//journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}), + path('power-panels//', include(get_model_urls('dcim', 'powerpanel'))), # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), @@ -493,7 +485,6 @@ path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), - path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), - path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), + path('power-feeds//', include(get_model_urls('dcim', 'powerfeed'))), ] diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ced3bd4b94f..18d0314bf7d 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,7 +1,7 @@ -from django.urls import path, re_path +from django.urls import include, path, re_path -from extras import models, views -from netbox.views.generic import ObjectChangeLogView +from extras import views +from utilities.urls import get_model_urls app_name = 'extras' @@ -16,8 +16,7 @@ path('custom-fields//', views.CustomFieldView.as_view(), name='customfield'), path('custom-fields//edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'), path('custom-fields//delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'), - path('custom-fields//changelog/', ObjectChangeLogView.as_view(), name='customfield_changelog', - kwargs={'model': models.CustomField}), + path('custom-fields//', include(get_model_urls('extras', 'customfield'))), # Custom links path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), @@ -28,8 +27,7 @@ path('custom-links//', views.CustomLinkView.as_view(), name='customlink'), path('custom-links//edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'), path('custom-links//delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'), - path('custom-links//changelog/', ObjectChangeLogView.as_view(), name='customlink_changelog', - kwargs={'model': models.CustomLink}), + path('custom-links//', include(get_model_urls('extras', 'customlink'))), # Export templates path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'), @@ -40,8 +38,7 @@ path('export-templates//', views.ExportTemplateView.as_view(), name='exporttemplate'), path('export-templates//edit/', views.ExportTemplateEditView.as_view(), name='exporttemplate_edit'), path('export-templates//delete/', views.ExportTemplateDeleteView.as_view(), name='exporttemplate_delete'), - path('export-templates//changelog/', ObjectChangeLogView.as_view(), name='exporttemplate_changelog', - kwargs={'model': models.ExportTemplate}), + path('export-templates//', include(get_model_urls('extras', 'exporttemplate'))), # Webhooks path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), @@ -52,8 +49,7 @@ path('webhooks//', views.WebhookView.as_view(), name='webhook'), path('webhooks//edit/', views.WebhookEditView.as_view(), name='webhook_edit'), path('webhooks//delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'), - path('webhooks//changelog/', ObjectChangeLogView.as_view(), name='webhook_changelog', - kwargs={'model': models.Webhook}), + path('webhooks//', include(get_model_urls('extras', 'webhook'))), # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), @@ -64,8 +60,7 @@ path('tags//', views.TagView.as_view(), name='tag'), path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path('tags//changelog/', ObjectChangeLogView.as_view(), name='tag_changelog', - kwargs={'model': models.Tag}), + path('tags//', include(get_model_urls('extras', 'tag'))), # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), @@ -75,8 +70,7 @@ path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), path('config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), path('config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), - path('config-contexts//changelog/', ObjectChangeLogView.as_view(), name='configcontext_changelog', - kwargs={'model': models.ConfigContext}), + path('config-contexts//', include(get_model_urls('extras', 'configcontext'))), # Image attachments path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'), @@ -91,8 +85,7 @@ path('journal-entries//', views.JournalEntryView.as_view(), name='journalentry'), path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), - path('journal-entries//changelog/', ObjectChangeLogView.as_view(), name='journalentry_changelog', - kwargs={'model': models.JournalEntry}), + path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index d27209fd207..76ea2934b7c 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,8 +1,7 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * app_name = 'ipam' urlpatterns = [ @@ -16,8 +15,7 @@ path('asns//', views.ASNView.as_view(), name='asn'), path('asns//edit/', views.ASNEditView.as_view(), name='asn_edit'), path('asns//delete/', views.ASNDeleteView.as_view(), name='asn_delete'), - path('asns//changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}), - path('asns//journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}), + path('asns//', include(get_model_urls('ipam', 'asn'))), # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), @@ -28,8 +26,7 @@ path('vrfs//', views.VRFView.as_view(), name='vrf'), path('vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), - path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), - path('vrfs//journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}), + path('vrfs//', include(get_model_urls('ipam', 'vrf'))), # Route targets path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), @@ -40,8 +37,7 @@ path('route-targets//', views.RouteTargetView.as_view(), name='routetarget'), path('route-targets//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), - path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), - path('route-targets//journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}), + path('route-targets//', include(get_model_urls('ipam', 'routetarget'))), # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), @@ -52,7 +48,7 @@ path('rirs//', views.RIRView.as_view(), name='rir'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), - path('rirs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path('rirs//', include(get_model_urls('ipam', 'rir'))), # Aggregates path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), @@ -64,8 +60,7 @@ path('aggregates//prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'), path('aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), - path('aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), - path('aggregates//journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}), + path('aggregates//', include(get_model_urls('ipam', 'aggregate'))), # Roles path('roles/', views.RoleListView.as_view(), name='role_list'), @@ -76,7 +71,7 @@ path('roles//', views.RoleView.as_view(), name='role'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), - path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path('roles//', include(get_model_urls('ipam', 'role'))), # Prefixes path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), @@ -87,11 +82,10 @@ path('prefixes//', views.PrefixView.as_view(), name='prefix'), path('prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), - path('prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), - path('prefixes//journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}), path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), path('prefixes//ip-ranges/', views.PrefixIPRangesView.as_view(), name='prefix_ipranges'), path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + path('prefixes//', include(get_model_urls('ipam', 'prefix'))), # IP ranges path('ip-ranges/', views.IPRangeListView.as_view(), name='iprange_list'), @@ -102,9 +96,8 @@ path('ip-ranges//', views.IPRangeView.as_view(), name='iprange'), path('ip-ranges//edit/', views.IPRangeEditView.as_view(), name='iprange_edit'), path('ip-ranges//delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'), - path('ip-ranges//changelog/', ObjectChangeLogView.as_view(), name='iprange_changelog', kwargs={'model': IPRange}), - path('ip-ranges//journal/', ObjectJournalView.as_view(), name='iprange_journal', kwargs={'model': IPRange}), path('ip-ranges//ip-addresses/', views.IPRangeIPAddressesView.as_view(), name='iprange_ipaddresses'), + path('ip-ranges//', include(get_model_urls('ipam', 'iprange'))), # IP addresses path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), @@ -113,12 +106,11 @@ path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), - path('ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), - path('ip-addresses//journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}), path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), path('ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + path('ip-addresses//', include(get_model_urls('ipam', 'ipaddress'))), # FHRP groups path('fhrp-groups/', views.FHRPGroupListView.as_view(), name='fhrpgroup_list'), @@ -129,8 +121,7 @@ path('fhrp-groups//', views.FHRPGroupView.as_view(), name='fhrpgroup'), path('fhrp-groups//edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'), path('fhrp-groups//delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'), - path('fhrp-groups//changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}), - path('fhrp-groups//journal/', ObjectJournalView.as_view(), name='fhrpgroup_journal', kwargs={'model': FHRPGroup}), + path('fhrp-groups//', include(get_model_urls('ipam', 'fhrpgroup'))), # FHRP group assignments path('fhrp-group-assignments/add/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_add'), @@ -146,7 +137,7 @@ path('vlan-groups//', views.VLANGroupView.as_view(), name='vlangroup'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), - path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), + path('vlan-groups//', include(get_model_urls('ipam', 'vlangroup'))), # VLANs path('vlans/', views.VLANListView.as_view(), name='vlan_list'), @@ -159,8 +150,7 @@ path('vlans//vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'), path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), - path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), - path('vlans//journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}), + path('vlans//', include(get_model_urls('ipam', 'vlan'))), # Service templates path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), @@ -171,8 +161,7 @@ path('service-templates//', views.ServiceTemplateView.as_view(), name='servicetemplate'), path('service-templates//edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'), path('service-templates//delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'), - path('service-templates//changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}), - path('service-templates//journal/', ObjectJournalView.as_view(), name='servicetemplate_journal', kwargs={'model': ServiceTemplate}), + path('service-templates//', include(get_model_urls('ipam', 'servicetemplate'))), # Services path('services/', views.ServiceListView.as_view(), name='service_list'), @@ -183,8 +172,7 @@ path('services//', views.ServiceView.as_view(), name='service'), path('services//edit/', views.ServiceEditView.as_view(), name='service_edit'), path('services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), - path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), - path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), + path('services//', include(get_model_urls('ipam', 'service'))), # L2VPN path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), @@ -195,9 +183,9 @@ path('l2vpns//', views.L2VPNView.as_view(), name='l2vpn'), path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), - path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), - path('l2vpns//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + path('l2vpns//', include(get_model_urls('ipam', 'l2vpn'))), + # L2VPN terminations path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), @@ -206,6 +194,5 @@ path('l2vpn-terminations//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), - path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), - path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//', include(get_model_urls('ipam', 'l2vpntermination'))), ] diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 21410027578..b55e949dd40 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,8 +1,7 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * app_name = 'tenancy' urlpatterns = [ @@ -16,7 +15,7 @@ path('tenant-groups//', views.TenantGroupView.as_view(), name='tenantgroup'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), - path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), + path('tenant-groups//', include(get_model_urls('tenancy', 'tenantgroup'))), # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), @@ -27,8 +26,7 @@ path('tenants//', views.TenantView.as_view(), name='tenant'), path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), - path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), - path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}), + path('tenants//', include(get_model_urls('tenancy', 'tenant'))), # Contact groups path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'), @@ -39,7 +37,7 @@ path('contact-groups//', views.ContactGroupView.as_view(), name='contactgroup'), path('contact-groups//edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'), path('contact-groups//delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'), - path('contact-groups//changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}), + path('contact-groups//', include(get_model_urls('tenancy', 'contactgroup'))), # Contact roles path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'), @@ -50,7 +48,7 @@ path('contact-roles//', views.ContactRoleView.as_view(), name='contactrole'), path('contact-roles//edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'), path('contact-roles//delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'), - path('contact-roles//changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}), + path('contact-roles//', include(get_model_urls('tenancy', 'contactrole'))), # Contacts path('contacts/', views.ContactListView.as_view(), name='contact_list'), @@ -61,8 +59,7 @@ path('contacts//', views.ContactView.as_view(), name='contact'), path('contacts//edit/', views.ContactEditView.as_view(), name='contact_edit'), path('contacts//delete/', views.ContactDeleteView.as_view(), name='contact_delete'), - path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}), - path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}), + path('contacts//', include(get_model_urls('tenancy', 'contact'))), # Contact assignments path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index e01dbc05967..8968414bc65 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,8 +1,7 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface app_name = 'virtualization' urlpatterns = [ @@ -16,7 +15,7 @@ path('cluster-types//', views.ClusterTypeView.as_view(), name='clustertype'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), - path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), + path('cluster-types//', include(get_model_urls('virtualization', 'clustertype'))), # Cluster groups path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), @@ -27,7 +26,7 @@ path('cluster-groups//', views.ClusterGroupView.as_view(), name='clustergroup'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), - path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), + path('cluster-groups//', include(get_model_urls('virtualization', 'clustergroup'))), # Clusters path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), @@ -40,10 +39,9 @@ path('clusters//virtual-machines/', views.ClusterVirtualMachinesView.as_view(), name='cluster_virtualmachines'), path('clusters//edit/', views.ClusterEditView.as_view(), name='cluster_edit'), path('clusters//delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), - path('clusters//changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), - path('clusters//journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}), path('clusters//devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), path('clusters//devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), + path('clusters//', include(get_model_urls('virtualization', 'cluster'))), # Virtual machines path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), @@ -56,8 +54,7 @@ path('virtual-machines//edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), - path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), - path('virtual-machines//journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}), + path('virtual-machines//', include(get_model_urls('virtualization', 'virtualmachine'))), # VM interfaces path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'), @@ -69,7 +66,7 @@ path('interfaces//', views.VMInterfaceView.as_view(), name='vminterface'), path('interfaces//edit/', views.VMInterfaceEditView.as_view(), name='vminterface_edit'), path('interfaces//delete/', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'), - path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='vminterface_changelog', kwargs={'model': VMInterface}), + path('interfaces//', include(get_model_urls('virtualization', 'vminterface'))), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'), ] diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index cef96fd5e3c..d6e84b1b83b 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -1,8 +1,7 @@ -from django.urls import path +from django.urls import include, path -from netbox.views.generic import ObjectChangeLogView, ObjectJournalView +from utilities.urls import get_model_urls from . import views -from .models import * app_name = 'wireless' urlpatterns = ( @@ -16,7 +15,7 @@ path('wireless-lan-groups//', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'), path('wireless-lan-groups//edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'), path('wireless-lan-groups//delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'), - path('wireless-lan-groups//changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}), + path('wireless-lan-groups//', include(get_model_urls('wireless', 'wirelesslangroup'))), # Wireless LANs path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), @@ -27,8 +26,7 @@ path('wireless-lans//', views.WirelessLANView.as_view(), name='wirelesslan'), path('wireless-lans//edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'), path('wireless-lans//delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'), - path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), - path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), + path('wireless-lans//', include(get_model_urls('wireless', 'wirelesslan'))), # Wireless links path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'), @@ -39,7 +37,6 @@ path('wireless-links//', views.WirelessLinkView.as_view(), name='wirelesslink'), path('wireless-links//edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'), path('wireless-links//delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'), - path('wireless-links//changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}), - path('wireless-links//journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}), + path('wireless-links//', include(get_model_urls('wireless', 'wirelesslink'))), ) From 10bb8fa10af10e976ff55e15b0cbcf87a4002b33 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 6 Oct 2022 13:50:53 -0700 Subject: [PATCH 049/179] 9478 link peers to GraphQL (#10574) * 9468 add link_peer to GraphQL * 9478 add class_type * 9478 fix tests * 9478 fix tests * 9478 fix tests --- netbox/dcim/graphql/gfk_mixins.py | 59 +++++++++++++++++++++++++++++++ netbox/dcim/graphql/mixins.py | 7 ++++ netbox/netbox/graphql/types.py | 18 +++++++--- netbox/utilities/testing/api.py | 6 +++- 4 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 netbox/dcim/graphql/gfk_mixins.py diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py new file mode 100644 index 00000000000..d6be138bc09 --- /dev/null +++ b/netbox/dcim/graphql/gfk_mixins.py @@ -0,0 +1,59 @@ +import graphene +from circuits.graphql.types import CircuitTerminationType +from circuits.models import CircuitTermination +from dcim.graphql.types import ( + ConsolePortType, + ConsoleServerPortType, + FrontPortType, + InterfaceType, + PowerFeedType, + PowerOutletType, + PowerPortType, + RearPortType, +) +from dcim.models import ( + ConsolePort, + ConsoleServerPort, + FrontPort, + Interface, + PowerFeed, + PowerOutlet, + PowerPort, + RearPort, +) + + +class LinkPeerType(graphene.Union): + class Meta: + types = ( + CircuitTerminationType, + ConsolePortType, + ConsoleServerPortType, + FrontPortType, + InterfaceType, + PowerFeedType, + PowerOutletType, + PowerPortType, + RearPortType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) == CircuitTermination: + return CircuitTerminationType + if type(instance) == ConsolePortType: + return ConsolePortType + if type(instance) == ConsoleServerPort: + return ConsoleServerPortType + if type(instance) == FrontPort: + return FrontPortType + if type(instance) == Interface: + return InterfaceType + if type(instance) == PowerFeed: + return PowerFeedType + if type(instance) == PowerOutlet: + return PowerOutletType + if type(instance) == PowerPort: + return PowerPortType + if type(instance) == RearPort: + return RearPortType diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py index d8488aa5f53..133d6259f48 100644 --- a/netbox/dcim/graphql/mixins.py +++ b/netbox/dcim/graphql/mixins.py @@ -1,5 +1,12 @@ +import graphene + + class CabledObjectMixin: + link_peers = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType') def resolve_cable_end(self, info): # Handle empty values return self.cable_end or None + + def resolve_link_peers(self, info): + return self.link_peers diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 41eff6d46e6..10847742bd7 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,10 +1,14 @@ import graphene from django.contrib.contenttypes.models import ContentType +from extras.graphql.mixins import ( + ChangelogMixin, + CustomFieldsMixin, + JournalEntriesMixin, + TagsMixin, +) from graphene_django import DjangoObjectType -from extras.graphql.mixins import ChangelogMixin, CustomFieldsMixin, JournalEntriesMixin, TagsMixin - __all__ = ( 'BaseObjectType', 'ObjectType', @@ -22,9 +26,7 @@ class BaseObjectType(DjangoObjectType): Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions. """ display = graphene.String() - - def resolve_display(parent, info, **kwargs): - return str(parent) + class_type = graphene.String() class Meta: abstract = True @@ -34,6 +36,12 @@ def get_queryset(cls, queryset, info): # Enforce object permissions on the queryset return queryset.restrict(info.context.user, 'view') + def resolve_display(parent, info, **kwargs): + return str(parent) + + def resolve_class_type(parent, info, **kwargs): + return parent.__class__.__name__ + class ObjectType( ChangelogMixin, diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index f26e5fffc32..8815ede1f74 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -1,3 +1,4 @@ +import inspect import json from django.conf import settings @@ -5,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.test import override_settings -from graphene.types import Dynamic as GQLDynamic, List as GQLList +from graphene.types import Dynamic as GQLDynamic, List as GQLList, Union as GQLUnion from rest_framework import status from rest_framework.test import APIClient @@ -449,6 +450,9 @@ def _build_query(self, name, **filters): if type(field) is GQLDynamic: # Dynamic fields must specify a subselection fields_string += f'{field_name} {{ id }}\n' + elif type(field.type) is GQLList and inspect.isclass(field.type.of_type) and issubclass(field.type.of_type, GQLUnion): + # Union types dont' have an id or consistent values + continue elif type(field.type) is GQLList and field_name != 'choices': # TODO: Come up with something more elegant # Temporary hack to support automated testing of reverse generic relations From 663652f45e42d638c34b97824b3110bfa781ff21 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Oct 2022 16:54:19 -0400 Subject: [PATCH 050/179] Changelog for #9478 --- docs/release-notes/version-3.4.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 313a84f2003..cc4d0d6f5f2 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -18,6 +18,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### Enhancements * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive +* [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type @@ -51,3 +52,4 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a ### GraphQL API Changes * All object types now include a `display` field +* All cabled object types now include a `link_peers` field From 4c999daacd94588ef328d4b663319970cf82131a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Oct 2022 10:54:34 -0400 Subject: [PATCH 051/179] Introduce ViewTab --- netbox/netbox/models/features.py | 7 +----- netbox/netbox/views/generic/feature_views.py | 11 ++++++++++ netbox/utilities/urls.py | 13 ++++++----- netbox/utilities/views.py | 23 +++++++++++--------- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 0d519a8baf2..5325cbcd1e8 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -13,7 +13,7 @@ from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder from utilities.utils import serialize_object -from utilities.views import register_model_view +from utilities.views import ViewTab, register_model_view __all__ = ( 'ChangeLoggingMixin', @@ -300,9 +300,6 @@ def _register_features(sender, **kwargs): sender, 'journal', 'netbox.views.generic.ObjectJournalView', - tab_label='Journal', - tab_badge=lambda x: x.journal_entries.count(), - tab_permission='extras.view_journalentry', kwargs={'model': sender} ) if issubclass(sender, ChangeLoggingMixin): @@ -310,7 +307,5 @@ def _register_features(sender, **kwargs): sender, 'changelog', 'netbox.views.generic.ObjectChangeLogView', - tab_label='Changelog', - tab_permission='extras.view_objectchange', kwargs={'model': sender} ) diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 85e675a69c7..963fad1964c 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -1,10 +1,12 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.shortcuts import get_object_or_404, render +from django.utils.translation import gettext as _ from django.views.generic import View from extras import forms, tables from extras.models import * +from utilities.views import ViewTab __all__ = ( 'ObjectChangeLogView', @@ -23,6 +25,10 @@ class ObjectChangeLogView(View): base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used. """ base_template = None + tab = ViewTab( + label=_('Changelog'), + permission='extras.view_objectchange' + ) def get(self, request, model, **kwargs): @@ -71,6 +77,11 @@ class ObjectJournalView(View): base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used. """ base_template = None + tab = ViewTab( + label=_('Journal'), + badge=lambda obj: obj.journal_entries.count(), + permission='extras.view_journalentry' + ) def get(self, request, model, **kwargs): diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py index 3920889b33c..2db8bc91ff4 100644 --- a/netbox/utilities/urls.py +++ b/netbox/utilities/urls.py @@ -22,14 +22,17 @@ def get_model_urls(app_label, model_name): # No views have been registered for this model views = [] - for view in views: + for config in views: # Import the view class or function - callable = import_string(view['path']) - if issubclass(callable, View): - callable = callable.as_view() + if type(config['view']) is str: + view_ = import_string(config['view']) + else: + view_ = config['view'] + if issubclass(view_, View): + view_ = view_.as_view() # Create a path to the view paths.append( - path(f"{view['name']}/", callable, name=f"{model_name}_{view['name']}", kwargs=view['kwargs']) + path(f"{config['name']}/", view_, name=f"{model_name}_{config['name']}", kwargs=config['kwargs']) ) return paths diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a4f5c79a9af..1200112bea5 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -10,6 +10,7 @@ 'ContentTypePermissionRequiredMixin', 'GetReturnURLMixin', 'ObjectPermissionRequiredMixin', + 'ViewTab', 'register_model_view', ) @@ -132,18 +133,23 @@ def get_return_url(self, request, obj=None): return reverse('home') -def register_model_view(model, name, view_path, tab_label=None, tab_badge=None, tab_permission=None, kwargs=None): +class ViewTab: + + def __init__(self, label, badge=None, permission=None, always_display=True): + self.label = label + self.badge = badge + self.permission = permission + self.always_display = always_display + + +def register_model_view(model, name, view, kwargs=None): """ Register a subview for a core model. Args: model: The Django model class with which this view will be associated name: The name to register when creating a URL path - view_path: A dotted path to the view class or function (e.g. 'myplugin.views.FooView') - tab_label: The label to display for the view's tab under the model view (optional) - tab_badge: A static value or callable to display a badge within the view's tab (optional). If a callable is - specified, it must accept the current object as its single positional argument. - tab_permission: The name of the permission required to display the tab (optional) + view: A class-based or function view, or the dotted path to it (e.g. 'myplugin.views.FooView') kwargs: A dictionary of keyword arguments to send to the view (optional) """ app_label = model._meta.app_label @@ -154,9 +160,6 @@ def register_model_view(model, name, view_path, tab_label=None, tab_badge=None, registry['views'][app_label][model_name].append({ 'name': name, - 'path': view_path, - 'tab_label': tab_label, - 'tab_badge': tab_badge, - 'tab_permission': tab_permission, + 'view': view, 'kwargs': kwargs or {}, }) From bfe26b46a6a0bb93bfcf8d16fe088e61d3d51295 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Oct 2022 11:36:14 -0400 Subject: [PATCH 052/179] Wrap model detail views with register_model_view() --- netbox/dcim/urls.py | 27 --- netbox/dcim/views.py | 217 +++++++++++++++++- netbox/extras/views.py | 2 +- netbox/ipam/urls.py | 5 - netbox/ipam/views.py | 38 ++- netbox/netbox/models/features.py | 8 +- netbox/templates/dcim/device/base.html | 89 ------- netbox/templates/dcim/devicetype/base.html | 82 ------- netbox/templates/dcim/moduletype/base.html | 58 ----- netbox/templates/ipam/aggregate/base.html | 10 - netbox/templates/ipam/iprange/base.html | 10 - netbox/templates/ipam/prefix/base.html | 18 -- .../virtualization/cluster/base.html | 17 -- .../virtualization/virtualmachine/base.html | 15 -- netbox/utilities/templatetags/tabs.py | 28 ++- netbox/utilities/urls.py | 3 +- netbox/utilities/views.py | 45 ++-- netbox/virtualization/urls.py | 4 - netbox/virtualization/views.py | 27 ++- 19 files changed, 320 insertions(+), 383 deletions(-) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 86d28e22460..b92a0eec955 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -105,16 +105,6 @@ path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'), - path('device-types//console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'), - path('device-types//console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'), - path('device-types//power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'), - path('device-types//power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'), - path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'), - path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'), - path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), - path('device-types//module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'), - path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), - path('device-types//inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//', include(get_model_urls('dcim', 'devicetype'))), @@ -126,13 +116,6 @@ path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'), path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'), path('module-types//', views.ModuleTypeView.as_view(), name='moduletype'), - path('module-types//console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'), - path('module-types//console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'), - path('module-types//power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'), - path('module-types//power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'), - path('module-types//interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'), - path('module-types//front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'), - path('module-types//rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'), path('module-types//edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'), path('module-types//delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'), path('module-types//', include(get_model_urls('dcim', 'moduletype'))), @@ -250,17 +233,7 @@ path('devices//', views.DeviceView.as_view(), name='device'), path('devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), path('devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), - path('devices//console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'), - path('devices//console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'), - path('devices//power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'), - path('devices//power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'), - path('devices//interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'), - path('devices//front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'), - path('devices//rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'), - path('devices//module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'), - path('devices//device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'), path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), - path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5930d6b2d30..e299357d15d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from django.views.generic import View from circuits.models import Circuit, CircuitTermination @@ -19,7 +20,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model from utilities.utils import count_related -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from virtualization.models import VirtualMachine from . import filtersets, forms, tables from .choices import DeviceFaceChoices @@ -47,7 +48,7 @@ def get_children(self, request, parent): def get_extra_context(self, request, instance): return { - 'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}", + 'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '')}", } @@ -60,10 +61,11 @@ def get_children(self, request, parent): return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent) def get_extra_context(self, request, instance): - context = super().get_extra_context(request, instance) - context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk}) - - return context + model_name = self.child_model._meta.verbose_name_plural + return { + 'active_tab': f"{model_name.replace(' ', '').replace('template', '')}", + 'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}), + } class ModuleTypeComponentsView(DeviceComponentsView): @@ -75,10 +77,11 @@ def get_children(self, request, parent): return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent) def get_extra_context(self, request, instance): - context = super().get_extra_context(request, instance) - context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk}) - - return context + model_name = self.child_model._meta.verbose_name_plural + return { + 'active_tab': f"{model_name.replace(' ', '').replace('template', '')}", + 'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}), + } class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): @@ -857,74 +860,144 @@ def get_extra_context(self, request, instance): } +@register_model_view(DeviceType, 'consoleports', path='console-ports') class DeviceTypeConsolePortsView(DeviceTypeComponentsView): child_model = ConsolePortTemplate table = tables.ConsolePortTemplateTable filterset = filtersets.ConsolePortTemplateFilterSet viewname = 'dcim:devicetype_consoleports' + tab = ViewTab( + label=_('Console Ports'), + badge=lambda obj: obj.consoleporttemplates.count(), + permission='dcim.view_consoleporttemplate', + always_display=False + ) +@register_model_view(DeviceType, 'consoleserverports', path='console-server-ports') class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView): child_model = ConsoleServerPortTemplate table = tables.ConsoleServerPortTemplateTable filterset = filtersets.ConsoleServerPortTemplateFilterSet viewname = 'dcim:devicetype_consoleserverports' + tab = ViewTab( + label=_('Console Server Ports'), + badge=lambda obj: obj.consoleserverporttemplates.count(), + permission='dcim.view_consoleserverporttemplate', + always_display=False + ) +@register_model_view(DeviceType, 'powerports', path='power-ports') class DeviceTypePowerPortsView(DeviceTypeComponentsView): child_model = PowerPortTemplate table = tables.PowerPortTemplateTable filterset = filtersets.PowerPortTemplateFilterSet viewname = 'dcim:devicetype_powerports' + tab = ViewTab( + label=_('Power Ports'), + badge=lambda obj: obj.powerporttemplates.count(), + permission='dcim.view_powerporttemplate', + always_display=False + ) +@register_model_view(DeviceType, 'poweroutlets', path='power-outlets') class DeviceTypePowerOutletsView(DeviceTypeComponentsView): child_model = PowerOutletTemplate table = tables.PowerOutletTemplateTable filterset = filtersets.PowerOutletTemplateFilterSet viewname = 'dcim:devicetype_poweroutlets' + tab = ViewTab( + label=_('Power Outlets'), + badge=lambda obj: obj.poweroutlettemplates.count(), + permission='dcim.view_poweroutlettemplate', + always_display=False + ) +@register_model_view(DeviceType, 'interfaces') class DeviceTypeInterfacesView(DeviceTypeComponentsView): child_model = InterfaceTemplate table = tables.InterfaceTemplateTable filterset = filtersets.InterfaceTemplateFilterSet viewname = 'dcim:devicetype_interfaces' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfacetemplates.count(), + permission='dcim.view_interfacetemplate', + always_display=False + ) +@register_model_view(DeviceType, 'frontports', path='front-ports') class DeviceTypeFrontPortsView(DeviceTypeComponentsView): child_model = FrontPortTemplate table = tables.FrontPortTemplateTable filterset = filtersets.FrontPortTemplateFilterSet viewname = 'dcim:devicetype_frontports' + tab = ViewTab( + label=_('Front Ports'), + badge=lambda obj: obj.frontporttemplates.count(), + permission='dcim.view_frontporttemplate', + always_display=False + ) +@register_model_view(DeviceType, 'rearports', path='rear-ports') class DeviceTypeRearPortsView(DeviceTypeComponentsView): child_model = RearPortTemplate table = tables.RearPortTemplateTable filterset = filtersets.RearPortTemplateFilterSet viewname = 'dcim:devicetype_rearports' + tab = ViewTab( + label=_('Rear Ports'), + badge=lambda obj: obj.rearporttemplates.count(), + permission='dcim.view_rearporttemplate', + always_display=False + ) +@register_model_view(DeviceType, 'modulebays', path='module-bays') class DeviceTypeModuleBaysView(DeviceTypeComponentsView): child_model = ModuleBayTemplate table = tables.ModuleBayTemplateTable filterset = filtersets.ModuleBayTemplateFilterSet viewname = 'dcim:devicetype_modulebays' + tab = ViewTab( + label=_('Module Bays'), + badge=lambda obj: obj.modulebaytemplates.count(), + permission='dcim.view_modulebaytemplate', + always_display=False + ) +@register_model_view(DeviceType, 'devicebays', path='device-bays') class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): child_model = DeviceBayTemplate table = tables.DeviceBayTemplateTable filterset = filtersets.DeviceBayTemplateFilterSet viewname = 'dcim:devicetype_devicebays' + tab = ViewTab( + label=_('Device Bays'), + badge=lambda obj: obj.devicebaytemplates.count(), + permission='dcim.view_devicebaytemplate', + always_display=False + ) +@register_model_view(DeviceType, 'inventoryitems', path='inventory-items') class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): child_model = InventoryItemTemplate table = tables.InventoryItemTemplateTable filterset = filtersets.InventoryItemTemplateFilterSet viewname = 'dcim:devicetype_inventoryitems' + tab = ViewTab( + label=_('Inventory Items'), + badge=lambda obj: obj.inventoryitemtemplates.count(), + permission='dcim.view_invenotryitemtemplate', + always_display=False + ) class DeviceTypeEditView(generic.ObjectEditView): @@ -1011,53 +1084,102 @@ def get_extra_context(self, request, instance): } +@register_model_view(ModuleType, 'consoleports', path='console-ports') class ModuleTypeConsolePortsView(ModuleTypeComponentsView): child_model = ConsolePortTemplate table = tables.ConsolePortTemplateTable filterset = filtersets.ConsolePortTemplateFilterSet viewname = 'dcim:moduletype_consoleports' + tab = ViewTab( + label=_('Console Ports'), + badge=lambda obj: obj.consoleporttemplates.count(), + permission='dcim.view_consoleporttemplate', + always_display=False + ) +@register_model_view(ModuleType, 'consoleserverports', path='console-server-ports') class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView): child_model = ConsoleServerPortTemplate table = tables.ConsoleServerPortTemplateTable filterset = filtersets.ConsoleServerPortTemplateFilterSet viewname = 'dcim:moduletype_consoleserverports' + tab = ViewTab( + label=_('Console Server Ports'), + badge=lambda obj: obj.consoleserverporttemplates.count(), + permission='dcim.view_consoleserverporttemplate', + always_display=False + ) +@register_model_view(ModuleType, 'powerports', path='power-ports') class ModuleTypePowerPortsView(ModuleTypeComponentsView): child_model = PowerPortTemplate table = tables.PowerPortTemplateTable filterset = filtersets.PowerPortTemplateFilterSet viewname = 'dcim:moduletype_powerports' + tab = ViewTab( + label=_('Power Ports'), + badge=lambda obj: obj.powerporttemplates.count(), + permission='dcim.view_powerporttemplate', + always_display=False + ) +@register_model_view(ModuleType, 'poweroutlets', path='power-outlets') class ModuleTypePowerOutletsView(ModuleTypeComponentsView): child_model = PowerOutletTemplate table = tables.PowerOutletTemplateTable filterset = filtersets.PowerOutletTemplateFilterSet viewname = 'dcim:moduletype_poweroutlets' + tab = ViewTab( + label=_('Power Outlets'), + badge=lambda obj: obj.poweroutlettemplates.count(), + permission='dcim.view_poweroutlettemplate', + always_display=False + ) +@register_model_view(ModuleType, 'interfaces') class ModuleTypeInterfacesView(ModuleTypeComponentsView): child_model = InterfaceTemplate table = tables.InterfaceTemplateTable filterset = filtersets.InterfaceTemplateFilterSet viewname = 'dcim:moduletype_interfaces' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfacetemplates.count(), + permission='dcim.view_interfacetemplate', + always_display=False + ) +@register_model_view(ModuleType, 'frontports', path='front-ports') class ModuleTypeFrontPortsView(ModuleTypeComponentsView): child_model = FrontPortTemplate table = tables.FrontPortTemplateTable filterset = filtersets.FrontPortTemplateFilterSet viewname = 'dcim:moduletype_frontports' + tab = ViewTab( + label=_('Front Ports'), + badge=lambda obj: obj.frontporttemplates.count(), + permission='dcim.view_frontporttemplate', + always_display=False + ) +@register_model_view(ModuleType, 'rearports', path='rear-ports') class ModuleTypeRearPortsView(ModuleTypeComponentsView): child_model = RearPortTemplate table = tables.RearPortTemplateTable filterset = filtersets.RearPortTemplateFilterSet viewname = 'dcim:moduletype_rearports' + tab = ViewTab( + label=_('Rear Ports'), + badge=lambda obj: obj.rearporttemplates.count(), + permission='dcim.view_rearporttemplate', + always_display=False + ) class ModuleTypeEditView(generic.ObjectEditView): @@ -1620,39 +1742,74 @@ def get_extra_context(self, request, instance): } +@register_model_view(Device, 'consoleports', path='console-ports') class DeviceConsolePortsView(DeviceComponentsView): child_model = ConsolePort table = tables.DeviceConsolePortTable filterset = filtersets.ConsolePortFilterSet template_name = 'dcim/device/consoleports.html' + tab = ViewTab( + label=_('Console Ports'), + badge=lambda obj: obj.consoleports.count(), + permission='dcim.view_consoleport', + always_display=False + ) +@register_model_view(Device, 'consoleserverports', path='console-server-ports') class DeviceConsoleServerPortsView(DeviceComponentsView): child_model = ConsoleServerPort table = tables.DeviceConsoleServerPortTable filterset = filtersets.ConsoleServerPortFilterSet template_name = 'dcim/device/consoleserverports.html' + tab = ViewTab( + label=_('Console Server Ports'), + badge=lambda obj: obj.consoleserverports.count(), + permission='dcim.view_consoleserverport', + always_display=False + ) +@register_model_view(Device, 'powerports', path='power-ports') class DevicePowerPortsView(DeviceComponentsView): child_model = PowerPort table = tables.DevicePowerPortTable filterset = filtersets.PowerPortFilterSet template_name = 'dcim/device/powerports.html' + tab = ViewTab( + label=_('Power Ports'), + badge=lambda obj: obj.powerports.count(), + permission='dcim.view_powerport', + always_display=False + ) +@register_model_view(Device, 'poweroutlets', path='power-outlets') class DevicePowerOutletsView(DeviceComponentsView): child_model = PowerOutlet table = tables.DevicePowerOutletTable filterset = filtersets.PowerOutletFilterSet template_name = 'dcim/device/poweroutlets.html' + tab = ViewTab( + label=_('Power Outlets'), + badge=lambda obj: obj.poweroutlets.count(), + permission='dcim.view_poweroutlet', + always_display=False + ) +@register_model_view(Device, 'interfaces') class DeviceInterfacesView(DeviceComponentsView): child_model = Interface table = tables.DeviceInterfaceTable filterset = filtersets.InterfaceFilterSet template_name = 'dcim/device/interfaces.html' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfaces.count(), + permission='dcim.view_interface', + always_display=False + ) def get_children(self, request, parent): return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related( @@ -1661,39 +1818,74 @@ def get_children(self, request, parent): ) +@register_model_view(Device, 'frontports', path='front-ports') class DeviceFrontPortsView(DeviceComponentsView): child_model = FrontPort table = tables.DeviceFrontPortTable filterset = filtersets.FrontPortFilterSet template_name = 'dcim/device/frontports.html' + tab = ViewTab( + label=_('Front Ports'), + badge=lambda obj: obj.frontports.count(), + permission='dcim.view_frontport', + always_display=False + ) +@register_model_view(Device, 'rearports', path='rear-ports') class DeviceRearPortsView(DeviceComponentsView): child_model = RearPort table = tables.DeviceRearPortTable filterset = filtersets.RearPortFilterSet template_name = 'dcim/device/rearports.html' + tab = ViewTab( + label=_('Rear Ports'), + badge=lambda obj: obj.rearports.count(), + permission='dcim.view_rearport', + always_display=False + ) +@register_model_view(Device, 'modulebays', path='module-bays') class DeviceModuleBaysView(DeviceComponentsView): child_model = ModuleBay table = tables.DeviceModuleBayTable filterset = filtersets.ModuleBayFilterSet template_name = 'dcim/device/modulebays.html' + tab = ViewTab( + label=_('Module Bays'), + badge=lambda obj: obj.modulebays.count(), + permission='dcim.view_modulebay', + always_display=False + ) +@register_model_view(Device, 'devicebays', path='device-bays') class DeviceDeviceBaysView(DeviceComponentsView): child_model = DeviceBay table = tables.DeviceDeviceBayTable filterset = filtersets.DeviceBayFilterSet template_name = 'dcim/device/devicebays.html' + tab = ViewTab( + label=_('Device Bays'), + badge=lambda obj: obj.devicebays.count(), + permission='dcim.view_devicebay', + always_display=False + ) +@register_model_view(Device, 'inventory') class DeviceInventoryView(DeviceComponentsView): child_model = InventoryItem table = tables.DeviceInventoryItemTable filterset = filtersets.InventoryItemFilterSet template_name = 'dcim/device/inventory.html' + tab = ViewTab( + label=_('Inventory Items'), + badge=lambda obj: obj.inventoryitems.count(), + permission='dcim.view_inventoryitem', + always_display=False + ) class DeviceStatusView(generic.ObjectView): @@ -1736,9 +1928,14 @@ def get_extra_context(self, request, instance): } +@register_model_view(Device, 'configcontext', path='config-context') class DeviceConfigContextView(ObjectConfigContextView): queryset = Device.objects.annotate_config_context_data() base_template = 'dcim/device/base.html' + tab = ViewTab( + label=_('Config Context'), + permission='extras.view_configcontext' + ) class DeviceEditView(generic.ObjectEditView): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d8a015bb03f..f95b3fb64a2 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -352,7 +352,7 @@ def get_extra_context(self, request, instance): 'source_contexts': source_contexts, 'format': format, 'base_template': self.base_template, - 'active_tab': 'config-context', + 'active_tab': 'configcontext', } diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 76ea2934b7c..c7b60045b0e 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -57,7 +57,6 @@ path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), path('aggregates//', views.AggregateView.as_view(), name='aggregate'), - path('aggregates//prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'), path('aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), path('aggregates//', include(get_model_urls('ipam', 'aggregate'))), @@ -82,9 +81,6 @@ path('prefixes//', views.PrefixView.as_view(), name='prefix'), path('prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), - path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), - path('prefixes//ip-ranges/', views.PrefixIPRangesView.as_view(), name='prefix_ipranges'), - path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), path('prefixes//', include(get_model_urls('ipam', 'prefix'))), # IP ranges @@ -96,7 +92,6 @@ path('ip-ranges//', views.IPRangeView.as_view(), name='iprange'), path('ip-ranges//edit/', views.IPRangeEditView.as_view(), name='iprange_edit'), path('ip-ranges//delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'), - path('ip-ranges//ip-addresses/', views.IPRangeIPAddressesView.as_view(), name='iprange_ipaddresses'), path('ip-ranges//', include(get_model_urls('ipam', 'iprange'))), # IP addresses diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 04d07e35664..f705664b3c3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,6 +3,7 @@ from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.translation import gettext as _ from circuits.models import Provider, Circuit from circuits.tables import ProviderTable @@ -11,6 +12,7 @@ from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related +from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables @@ -289,12 +291,18 @@ class AggregateView(generic.ObjectView): queryset = Aggregate.objects.all() +@register_model_view(Aggregate, 'prefixes') class AggregatePrefixesView(generic.ObjectChildrenView): queryset = Aggregate.objects.all() child_model = Prefix table = tables.PrefixTable filterset = filtersets.PrefixFilterSet template_name = 'ipam/aggregate/prefixes.html' + tab = ViewTab( + label=_('Prefixes'), + badge=lambda x: x.get_child_prefixes().count(), + permission='ipam.view_prefix' + ) def get_children(self, request, parent): return Prefix.objects.restrict(request.user, 'view').filter( @@ -466,12 +474,18 @@ def get_extra_context(self, request, instance): } +@register_model_view(Prefix, 'prefixes') class PrefixPrefixesView(generic.ObjectChildrenView): queryset = Prefix.objects.all() child_model = Prefix table = tables.PrefixTable filterset = filtersets.PrefixFilterSet template_name = 'ipam/prefix/prefixes.html' + tab = ViewTab( + label=_('Child Prefixes'), + badge=lambda x: x.get_child_prefixes().count(), + permission='ipam.view_prefix' + ) def get_children(self, request, parent): return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( @@ -495,12 +509,18 @@ def get_extra_context(self, request, instance): } +@register_model_view(Prefix, 'ipranges', path='ip-ranges') class PrefixIPRangesView(generic.ObjectChildrenView): queryset = Prefix.objects.all() child_model = IPRange table = tables.IPRangeTable filterset = filtersets.IPRangeFilterSet template_name = 'ipam/prefix/ip_ranges.html' + tab = ViewTab( + label=_('Child Ranges'), + badge=lambda x: x.get_child_ranges().count(), + permission='ipam.view_iprange' + ) def get_children(self, request, parent): return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( @@ -510,17 +530,23 @@ def get_children(self, request, parent): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", - 'active_tab': 'ip-ranges', + 'active_tab': 'ipranges', 'first_available_ip': instance.get_first_available_ip(), } +@register_model_view(Prefix, 'ipaddresses', path='ip-addresses') class PrefixIPAddressesView(generic.ObjectChildrenView): queryset = Prefix.objects.all() child_model = IPAddress table = tables.IPAddressTable filterset = filtersets.IPAddressFilterSet template_name = 'ipam/prefix/ip_addresses.html' + tab = ViewTab( + label=_('IP Addresses'), + badge=lambda x: x.get_child_ips().count(), + permission='ipam.view_ipaddress' + ) def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') @@ -533,7 +559,7 @@ def prep_table_data(self, request, queryset, parent): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", - 'active_tab': 'ip-addresses', + 'active_tab': 'ipaddresses', 'first_available_ip': instance.get_first_available_ip(), } @@ -581,19 +607,25 @@ class IPRangeView(generic.ObjectView): queryset = IPRange.objects.all() +@register_model_view(IPRange, 'ipaddresses', path='ip-addresses') class IPRangeIPAddressesView(generic.ObjectChildrenView): queryset = IPRange.objects.all() child_model = IPAddress table = tables.IPAddressTable filterset = filtersets.IPAddressFilterSet template_name = 'ipam/iprange/ip_addresses.html' + tab = ViewTab( + label=_('IP Addresses'), + badge=lambda x: x.get_child_ips().count(), + permission='ipam.view_ipaddress' + ) def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view') def get_extra_context(self, request, instance): return { - 'active_tab': 'ip-addresses', + 'active_tab': 'ipaddresses', } diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 5325cbcd1e8..f59e72c145f 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -13,7 +13,7 @@ from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder from utilities.utils import serialize_object -from utilities.views import ViewTab, register_model_view +from utilities.views import register_model_view __all__ = ( 'ChangeLoggingMixin', @@ -299,13 +299,11 @@ def _register_features(sender, **kwargs): register_model_view( sender, 'journal', - 'netbox.views.generic.ObjectJournalView', kwargs={'model': sender} - ) + )('netbox.views.generic.ObjectJournalView') if issubclass(sender, ChangeLoggingMixin): register_model_view( sender, 'changelog', - 'netbox.views.generic.ObjectChangeLogView', kwargs={'model': sender} - ) + )('netbox.views.generic.ObjectChangeLogView') diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index ea67154b1e6..161e41256dd 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -56,87 +56,6 @@ {% endblock %} {% block extra_tabs %} - {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='module-bays' modulebay_count=object.modulebays.count %} - {% if active_tab == tab_name or modulebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='interfaces' interface_count=object.interfaces_count %} - {% if active_tab == tab_name or interface_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='front-ports' frontport_count=object.frontports.count %} - {% if active_tab == tab_name or frontport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='rear-ports' rearport_count=object.rearports.count %} - {% if active_tab == tab_name or rearport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-ports' consoleport_count=object.consoleports.count %} - {% if active_tab == tab_name or consoleport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-server-ports' consoleserverport_count=object.consoleserverports.count %} - {% if active_tab == tab_name or consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-ports' powerport_count=object.powerports.count %} - {% if active_tab == tab_name or powerport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-outlets' poweroutlet_count=object.poweroutlets.count %} - {% if active_tab == tab_name or poweroutlet_count %} - - {% endif %} - {% endwith %} - - - {% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %} - {% if active_tab == tab_name or inventoryitem_count %} - - {% endif %} - {% endwith %} - {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %} {# NAPALM-enabled tabs #} {% endif %} - - {% if perms.extras.view_configcontext %} - - {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index 83ee1f41ee1..916952dfb90 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -51,85 +51,3 @@
{% endif %} {% endblock %} - -{% block extra_tabs %} - {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='module-bay-templates' modulebay_count=object.modulebaytemplates.count %} - {% if active_tab == tab_name or modulebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='interface-templates' interface_count=object.interfacetemplates.count %} - {% if active_tab == tab_name or interface_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='front-port-templates' frontport_count=object.frontporttemplates.count %} - {% if active_tab == tab_name or frontport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='rear-port-templates' rearport_count=object.rearporttemplates.count %} - {% if active_tab == tab_name or rearport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-port-templates' consoleport_count=object.consoleporttemplates.count %} - {% if active_tab == tab_name or consoleport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-server-port-templates' consoleserverport_count=object.consoleserverporttemplates.count %} - {% if active_tab == tab_name or consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-port-templates' powerport_count=object.powerporttemplates.count %} - {% if active_tab == tab_name or powerport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-outlet-templates' poweroutlet_count=object.poweroutlettemplates.count %} - {% if active_tab == tab_name or poweroutlet_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %} - {% if active_tab == tab_name or inventoryitem_count %} - - {% endif %} - {% endwith %} -{% endblock %} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html index f5713efc373..148effec248 100644 --- a/netbox/templates/dcim/moduletype/base.html +++ b/netbox/templates/dcim/moduletype/base.html @@ -42,61 +42,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with interface_count=object.interfacetemplates.count %} - {% if interface_count %} - - {% endif %} - {% endwith %} - - {% with frontport_count=object.frontporttemplates.count %} - {% if frontport_count %} - - {% endif %} - {% endwith %} - - {% with rearport_count=object.rearporttemplates.count %} - {% if rearport_count %} - - {% endif %} - {% endwith %} - - {% with consoleport_count=object.consoleporttemplates.count %} - {% if consoleport_count %} - - {% endif %} - {% endwith %} - - {% with consoleserverport_count=object.consoleserverporttemplates.count %} - {% if consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with powerport_count=object.powerporttemplates.count %} - {% if powerport_count %} - - {% endif %} - {% endwith %} - - {% with poweroutlet_count=object.poweroutlettemplates.count %} - {% if poweroutlet_count %} - - {% endif %} - {% endwith %} -{% endblock %} diff --git a/netbox/templates/ipam/aggregate/base.html b/netbox/templates/ipam/aggregate/base.html index c69661a65d3..968c4a041b2 100644 --- a/netbox/templates/ipam/aggregate/base.html +++ b/netbox/templates/ipam/aggregate/base.html @@ -6,13 +6,3 @@ {{ block.super }} {% endblock %} - -{% block extra_tabs %} - {% if perms.ipam.view_prefix %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/iprange/base.html b/netbox/templates/ipam/iprange/base.html index 30e8582644b..e97db855714 100644 --- a/netbox/templates/ipam/iprange/base.html +++ b/netbox/templates/ipam/iprange/base.html @@ -8,13 +8,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% if perms.ipam.view_ipaddress %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/prefix/base.html b/netbox/templates/ipam/prefix/base.html index b543e37ac2c..7ac30701406 100644 --- a/netbox/templates/ipam/prefix/base.html +++ b/netbox/templates/ipam/prefix/base.html @@ -8,21 +8,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - - - -{% endblock %} diff --git a/netbox/templates/virtualization/cluster/base.html b/netbox/templates/virtualization/cluster/base.html index 69b55ec6b4e..eb9eefe0ef0 100644 --- a/netbox/templates/virtualization/cluster/base.html +++ b/netbox/templates/virtualization/cluster/base.html @@ -24,20 +24,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with virtualmachine_count=object.virtual_machines.count %} - - {% endwith %} - {% with device_count=object.devices.count %} - - {% endwith %} -{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 946467e31f2..995c16fb00f 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -21,18 +21,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with interface_count=object.interfaces.count %} - {% if interface_count %} - - {% endif %} - {% endwith %} - {% if perms.extras.view_configcontext %} - - {% endif %} -{% endblock %} diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py index 13b4a5f632c..e0ab49589d7 100644 --- a/netbox/utilities/templatetags/tabs.py +++ b/netbox/utilities/templatetags/tabs.py @@ -1,6 +1,6 @@ from django import template -from django.core.exceptions import ImproperlyConfigured from django.urls import reverse +from django.utils.module_loading import import_string from extras.registry import registry @@ -26,23 +26,27 @@ def model_view_tabs(context, instance): views = [] # Compile a list of tabs to be displayed in the UI - for view in views: - if view['tab_label'] and (not view['tab_permission'] or user.has_perm(view['tab_permission'])): + for config in views: + view = import_string(config['view']) if type(config['view']) is str else config['view'] + if tab := getattr(view, 'tab', None): + if tab.permission and not user.has_perm(tab.permission): + continue # Determine the value of the tab's badge (if any) - if view['tab_badge'] and callable(view['tab_badge']): - badge_value = view['tab_badge'](instance) - elif view['tab_badge']: - badge_value = view['tab_badge'] + if tab.badge and callable(tab.badge): + badge_value = tab.badge(instance) else: - badge_value = None + badge_value = tab.badge + + if not tab.always_display and not badge_value: + continue tabs.append({ - 'name': view['name'], - 'url': reverse(f"{app_label}:{model_name}_{view['name']}", args=[instance.pk]), - 'label': view['tab_label'], + 'name': config['name'], + 'url': reverse(f"{app_label}:{model_name}_{config['name']}", args=[instance.pk]), + 'label': tab.label, 'badge_value': badge_value, - 'is_active': context.get('active_tab') == view['name'], + 'is_active': context.get('active_tab') == config['name'], }) return { diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py index 2db8bc91ff4..9ba2a65e614 100644 --- a/netbox/utilities/urls.py +++ b/netbox/utilities/urls.py @@ -30,9 +30,10 @@ def get_model_urls(app_label, model_name): view_ = config['view'] if issubclass(view_, View): view_ = view_.as_view() + # Create a path to the view paths.append( - path(f"{config['name']}/", view_, name=f"{model_name}_{config['name']}", kwargs=config['kwargs']) + path(f"{config['path']}/", view_, name=f"{model_name}_{config['name']}", kwargs=config['kwargs']) ) return paths diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1200112bea5..5a357111a4b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -142,24 +142,39 @@ def __init__(self, label, badge=None, permission=None, always_display=True): self.always_display = always_display -def register_model_view(model, name, view, kwargs=None): +def register_model_view(model, name, path=None, kwargs=None): """ - Register a subview for a core model. + This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject + additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model: + + @netbox_model_view(Site, 'myview', path='my-custom-view') + class MyView(ObjectView): + ... + + This will automatically create a URL path for MyView at `/dcim/sites//my-custom-view/` which can be + resolved using the view name `dcim:site_myview'. Args: - model: The Django model class with which this view will be associated - name: The name to register when creating a URL path - view: A class-based or function view, or the dotted path to it (e.g. 'myplugin.views.FooView') - kwargs: A dictionary of keyword arguments to send to the view (optional) + model: The Django model class with which this view will be associated. + name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended + to the name of the base view for the model using an underscore. + path: The URL path by which the view can be reached (optional). If not provided, `name` will be used. + kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional) """ - app_label = model._meta.app_label - model_name = model._meta.model_name + def _wrapper(cls): + app_label = model._meta.app_label + model_name = model._meta.model_name + + if model_name not in registry['views'][app_label]: + registry['views'][app_label][model_name] = [] + + registry['views'][app_label][model_name].append({ + 'name': name, + 'view': cls, + 'path': path or name, + 'kwargs': kwargs or {}, + }) - if model_name not in registry['views'][app_label]: - registry['views'][app_label][model_name] = [] + return cls - registry['views'][app_label][model_name].append({ - 'name': name, - 'view': view, - 'kwargs': kwargs or {}, - }) + return _wrapper diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 8968414bc65..31914bc3b0d 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -35,8 +35,6 @@ path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), path('clusters//', views.ClusterView.as_view(), name='cluster'), - path('clusters//devices/', views.ClusterDevicesView.as_view(), name='cluster_devices'), - path('clusters//virtual-machines/', views.ClusterVirtualMachinesView.as_view(), name='cluster_virtualmachines'), path('clusters//edit/', views.ClusterEditView.as_view(), name='cluster_edit'), path('clusters//delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), path('clusters//devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), @@ -50,10 +48,8 @@ path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), path('virtual-machines//', views.VirtualMachineView.as_view(), name='virtualmachine'), - path('virtual-machines//interfaces/', views.VirtualMachineInterfacesView.as_view(), name='virtualmachine_interfaces'), path('virtual-machines//edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), - path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), path('virtual-machines//', include(get_model_urls('virtualization', 'virtualmachine'))), # VM interfaces diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 611725d62e5..3289c0b5674 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -3,6 +3,7 @@ from django.db.models import Prefetch from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.translation import gettext as _ from dcim.filtersets import DeviceFilterSet from dcim.models import Device @@ -12,6 +13,7 @@ from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.utils import count_related +from utilities.views import ViewTab, register_model_view from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -161,28 +163,40 @@ class ClusterView(generic.ObjectView): queryset = Cluster.objects.all() +@register_model_view(Cluster, 'virtualmachines', path='virtual-machines') class ClusterVirtualMachinesView(generic.ObjectChildrenView): queryset = Cluster.objects.all() child_model = VirtualMachine table = tables.VirtualMachineTable filterset = filtersets.VirtualMachineFilterSet template_name = 'virtualization/cluster/virtual_machines.html' + tab = ViewTab( + label=_('Virtual Machines'), + badge=lambda obj: obj.virtual_machines.count(), + permission='virtualization.view_virtualmachine' + ) def get_children(self, request, parent): return VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=parent) def get_extra_context(self, request, instance): return { - 'active_tab': 'virtual-machines', + 'active_tab': 'virtualmachines', } +@register_model_view(Cluster, 'devices') class ClusterDevicesView(generic.ObjectChildrenView): queryset = Cluster.objects.all() child_model = Device table = DeviceTable filterset = DeviceFilterSet template_name = 'virtualization/cluster/devices.html' + tab = ViewTab( + label=_('Devices'), + badge=lambda obj: obj.devices.count(), + permission='virtualization.view_virtualmachine' + ) def get_children(self, request, parent): return Device.objects.restrict(request.user, 'view').filter(cluster=parent) @@ -344,12 +358,18 @@ def get_extra_context(self, request, instance): } +@register_model_view(VirtualMachine, 'interfaces') class VirtualMachineInterfacesView(generic.ObjectChildrenView): queryset = VirtualMachine.objects.all() child_model = VMInterface table = tables.VirtualMachineVMInterfaceTable filterset = filtersets.VMInterfaceFilterSet template_name = 'virtualization/virtualmachine/interfaces.html' + tab = ViewTab( + label=_('Interfaces'), + badge=lambda obj: obj.interfaces.count(), + permission='virtualization.view_vminterface' + ) def get_children(self, request, parent): return parent.interfaces.restrict(request.user, 'view').prefetch_related( @@ -363,9 +383,14 @@ def get_extra_context(self, request, instance): } +@register_model_view(VirtualMachine, 'configcontext', path='config-context') class VirtualMachineConfigContextView(ObjectConfigContextView): queryset = VirtualMachine.objects.annotate_config_context_data() base_template = 'virtualization/virtualmachine.html' + tab = ViewTab( + label=_('Config Context'), + permission='extras.view_configcontext' + ) class VirtualMachineEditView(generic.ObjectEditView): From 5e1a0733e4b439137e2e671b9b7404375db70809 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Oct 2022 12:14:19 -0400 Subject: [PATCH 053/179] Replace active_tab context for object views --- netbox/dcim/views.py | 9 ------ netbox/extras/views.py | 1 - netbox/ipam/urls.py | 2 -- netbox/ipam/views.py | 31 ++++++++------------ netbox/netbox/views/generic/base.py | 1 + netbox/netbox/views/generic/feature_views.py | 4 +-- netbox/netbox/views/generic/object_views.py | 3 +- netbox/templates/generic/object.html | 2 +- netbox/templates/ipam/vlan/base.html | 24 --------------- netbox/utilities/templatetags/tabs.py | 6 ++-- netbox/virtualization/views.py | 15 ---------- 11 files changed, 22 insertions(+), 76 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e299357d15d..d5aed58975d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -46,11 +46,6 @@ class DeviceComponentsView(generic.ObjectChildrenView): def get_children(self, request, parent): return self.child_model.objects.restrict(request.user, 'view').filter(device=parent) - def get_extra_context(self, request, instance): - return { - 'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '')}", - } - class DeviceTypeComponentsView(DeviceComponentsView): queryset = DeviceType.objects.all() @@ -61,9 +56,7 @@ def get_children(self, request, parent): return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent) def get_extra_context(self, request, instance): - model_name = self.child_model._meta.verbose_name_plural return { - 'active_tab': f"{model_name.replace(' ', '').replace('template', '')}", 'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}), } @@ -77,9 +70,7 @@ def get_children(self, request, parent): return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent) def get_extra_context(self, request, instance): - model_name = self.child_model._meta.verbose_name_plural return { - 'active_tab': f"{model_name.replace(' ', '').replace('template', '')}", 'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}), } diff --git a/netbox/extras/views.py b/netbox/extras/views.py index f95b3fb64a2..e48fd672bb8 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -352,7 +352,6 @@ def get_extra_context(self, request, instance): 'source_contexts': source_contexts, 'format': format, 'base_template': self.base_template, - 'active_tab': 'configcontext', } diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index c7b60045b0e..d5594eeb927 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -141,8 +141,6 @@ path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans//', views.VLANView.as_view(), name='vlan'), - path('vlans//interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'), - path('vlans//vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'), path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), path('vlans//', include(get_model_urls('ipam', 'vlan'))), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f705664b3c3..fba577f0298 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -319,7 +319,6 @@ def prep_table_data(self, request, queryset, parent): def get_extra_context(self, request, instance): 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'), @@ -502,7 +501,6 @@ def prep_table_data(self, request, queryset, parent): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&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'), @@ -530,7 +528,6 @@ def get_children(self, request, parent): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", - 'active_tab': 'ipranges', 'first_available_ip': instance.get_first_available_ip(), } @@ -559,7 +556,6 @@ def prep_table_data(self, request, queryset, parent): def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", - 'active_tab': 'ipaddresses', 'first_available_ip': instance.get_first_available_ip(), } @@ -623,11 +619,6 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view') - def get_extra_context(self, request, instance): - return { - 'active_tab': 'ipaddresses', - } - class IPRangeEditView(generic.ObjectEditView): queryset = IPRange.objects.all() @@ -1032,37 +1023,39 @@ def get_extra_context(self, request, instance): } +@register_model_view(VLAN, 'interfaces') class VLANInterfacesView(generic.ObjectChildrenView): queryset = VLAN.objects.all() child_model = Interface table = tables.VLANDevicesTable filterset = InterfaceFilterSet template_name = 'ipam/vlan/interfaces.html' + tab = ViewTab( + label=_('Device Interfaces'), + badge=lambda x: x.get_interfaces().count(), + permission='dcim.view_interface' + ) def get_children(self, request, parent): return parent.get_interfaces().restrict(request.user, 'view') - def get_extra_context(self, request, instance): - return { - 'active_tab': 'interfaces', - } - +@register_model_view(VLAN, 'vminterfaces', path='vm-interfaces') class VLANVMInterfacesView(generic.ObjectChildrenView): queryset = VLAN.objects.all() child_model = VMInterface table = tables.VLANVirtualMachinesTable filterset = VMInterfaceFilterSet template_name = 'ipam/vlan/vminterfaces.html' + tab = ViewTab( + label=_('VM Interfaces'), + badge=lambda x: x.get_vminterfaces().count(), + permission='virtualization.view_vminterface' + ) def get_children(self, request, parent): return parent.get_vminterfaces().restrict(request.user, 'view') - def get_extra_context(self, request, instance): - return { - 'active_tab': 'vminterfaces', - } - class VLANEditView(generic.ObjectEditView): queryset = VLAN.objects.all() diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 3ad3bcf679c..3a85df61808 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -14,6 +14,7 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View): """ queryset = None template_name = None + tab = None def get_object(self, **kwargs): """ diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 963fad1964c..ce5b29eb2ee 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -62,7 +62,7 @@ def get(self, request, model, **kwargs): 'object': obj, 'table': objectchanges_table, 'base_template': self.base_template, - 'active_tab': 'changelog', + 'tab': self.tab, }) @@ -122,5 +122,5 @@ def get(self, request, model, **kwargs): 'form': form, 'table': journalentry_table, 'base_template': self.base_template, - 'active_tab': 'journal', + 'tab': self.tab, }) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index a56a832b6e5..941eee72e66 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -5,7 +5,6 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError -from django.forms.widgets import HiddenInput from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import escape @@ -67,6 +66,7 @@ def get(self, request, **kwargs): return render(request, self.get_template_name(), { 'object': instance, + 'tab': self.tab, **self.get_extra_context(request, instance), }) @@ -141,6 +141,7 @@ def get(self, request, *args, **kwargs): 'child_model': self.child_model, 'table': table, 'actions': actions, + 'tab': self.tab, **self.get_extra_context(request, instance), }) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 2c3c76329d3..023726a30e6 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -81,7 +81,7 @@