From 62e5680eaffd8de0017254f0d0ef04a81164a3dc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Dec 2021 15:35:40 -0500 Subject: [PATCH 001/271] Closes #7731: Require Python 3.8 or later --- .github/workflows/ci.yml | 2 +- docs/index.md | 2 +- docs/installation/3-netbox.md | 15 +++++---------- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 6 +++--- docs/plugins/development.md | 4 ++-- docs/release-notes/version-3.2.md | 10 ++++++++++ netbox/netbox/settings.py | 10 +++------- 8 files changed, 26 insertions(+), 25 deletions(-) create mode 100644 docs/release-notes/version-3.2.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8e3f47ab..a898ab58d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.8, 3.9, 3.10] node-version: [14.x] services: redis: diff --git a/docs/index.md b/docs/index.md index 7abbd9310..02e523825 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and ## Supported Python Versions -NetBox supports Python 3.7, 3.8, and 3.9 environments currently. (Support for Python 3.6 was removed in NetBox v3.0.) +NetBox supports Python 3.8, 3.9, and 3.10 environments. ## Getting Started diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index b1e1e832e..cf9c19641 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -6,8 +6,8 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. -!!! warning "Python 3.7 or later required" - NetBox v3.0 and v3.1 require Python 3.7, 3.8, or 3.9. It is recommended to install at least Python v3.8, as this will become the minimum supported Python version in NetBox v3.2. +!!! warning "Python 3.8 or later required" + NetBox v3.2 requires Python 3.8, 3.9, or 3.10. === "Ubuntu" @@ -17,16 +17,11 @@ Begin by installing all system packages required by NetBox and its dependencies. === "CentOS" - !!! warning - CentOS 8 does not provide Python 3.7 or later via its native package manager. You will need to install it via some other means. [Here is an example](https://tecadmin.net/install-python-3-7-on-centos-8/) of installing Python 3.7 from source. - - Once you have Python 3.7 or later installed, install the remaining system packages: - ```no-highlight sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config ``` -Before continuing, check that your installed Python version is at least 3.7: +Before continuing, check that your installed Python version is at least 3.8: ```no-highlight python3 -V @@ -234,10 +229,10 @@ Once NetBox has been configured, we're ready to proceed with the actual installa sudo /opt/netbox/upgrade.sh ``` -Note that **Python 3.7 or later is required** for NetBox v3.0 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) +Note that **Python 3.8 or later is required** for NetBox v3.2 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) ```no-highlight -sudo PYTHON=/usr/bin/python3.7 /opt/netbox/upgrade.sh +sudo PYTHON=/usr/bin/python3.8 /opt/netbox/upgrade.sh ``` !!! note diff --git a/docs/installation/index.md b/docs/installation/index.md index 74b51da7f..accabd8cc 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -19,7 +19,7 @@ The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for | Dependency | Minimum Version | |------------|-----------------| -| Python | 3.7 | +| Python | 3.8 | | PostgreSQL | 10 | | Redis | 4.0 | diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index d7f7156c8..56b66c10d 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -10,7 +10,7 @@ NetBox v3.0 and later requires the following: | Dependency | Minimum Version | |------------|-----------------| -| Python | 3.7 | +| Python | 3.8 | | PostgreSQL | 10 | | Redis | 4.0 | @@ -76,10 +76,10 @@ sudo ./upgrade.sh ``` !!! warning - If the default version of Python is not at least 3.7, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: + If the default version of Python is not at least 3.8, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: ```no-highlight - sudo PYTHON=/usr/bin/python3.7 ./upgrade.sh + sudo PYTHON=/usr/bin/python3.8 ./upgrade.sh ``` This script performs the following actions: diff --git a/docs/plugins/development.md b/docs/plugins/development.md index cde659a45..89436a321 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -48,7 +48,7 @@ The plugin source directory contains all the actual Python code and other resour ### Create setup.py -`setup.py` is the [setup script](https://docs.python.org/3.7/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: +`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: ```python from setuptools import find_packages, setup @@ -129,7 +129,7 @@ python3 -m venv /path/to/my/venv You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) ```shell -cd $VENV/lib/python3.7/site-packages/ +cd $VENV/lib/python3.8/site-packages/ echo /opt/netbox/netbox > netbox.pth ``` diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md new file mode 100644 index 000000000..2c4dafc5a --- /dev/null +++ b/docs/release-notes/version-3.2.md @@ -0,0 +1,10 @@ +# NetBox v3.2 + +## v3.2.0 (FUTURE) + +!!! warning "Python 3.8 or Later Required" + NetBox v3.2 requires Python 3.8 or later. + +### Other Changes + +* [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 40c31b74d..9bc0dbc0c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.1-dev' +VERSION = '3.2.0-dev' # Hostname HOSTNAME = platform.node() @@ -28,13 +28,9 @@ HOSTNAME = platform.node() BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Validate Python version -if sys.version_info < (3, 7): - raise RuntimeError( - f"NetBox requires Python 3.7 or later. (Currently installed: Python {platform.python_version()})" - ) if sys.version_info < (3, 8): - warnings.warn( - f"NetBox v3.2 will require Python 3.8 or later. (Currently installed: Python {platform.python_version()})" + raise RuntimeError( + f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})" ) From 78ca6f1a8768420ae888803d58ef492ef308bdc0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Dec 2021 15:39:50 -0500 Subject: [PATCH 002/271] Use strings for build matrix --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a898ab58d..9b0392d89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, 3.10] - node-version: [14.x] + python-version: ['3.8', '3.9', '3.10'] + node-version: ['14.x'] services: redis: image: redis From 86809819906cc82b06e94a8e1b0fb2b4ab6ca8fd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Dec 2021 15:43:41 -0500 Subject: [PATCH 003/271] Closes #8031: Remove automatic redirection of legacy slug-based URLs --- docs/release-notes/version-3.2.md | 5 +++++ netbox/circuits/urls.py | 2 -- netbox/dcim/urls.py | 2 -- netbox/tenancy/urls.py | 2 -- netbox/utilities/views.py | 11 ----------- 5 files changed, 5 insertions(+), 17 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 2c4dafc5a..953ac36fe 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -5,6 +5,11 @@ !!! warning "Python 3.8 or Later Required" NetBox v3.2 requires Python 3.8 or later. +### Breaking Changes + +* Automatic redirection of legacy slug-based URL paths has been removed. + ### Other Changes * [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later +* [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 1cea1965e..e634eeeb4 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -2,7 +2,6 @@ from django.urls import path from dcim.views import CableCreateView, PathTraceView from extras.views import ObjectChangeLogView, ObjectJournalView -from utilities.views import SlugRedirectView from . import views from .models import * @@ -16,7 +15,6 @@ urlpatterns = [ path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers//', views.ProviderView.as_view(), name='provider'), - path('providers//', SlugRedirectView.as_view(), kwargs={'model': 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}), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index dd81ca2ba..713116f5e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -2,7 +2,6 @@ from django.urls import path from extras.views import ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView -from utilities.views import SlugRedirectView from . import views from .models import * @@ -38,7 +37,6 @@ urlpatterns = [ path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), path('sites//', views.SiteView.as_view(), name='site'), - path('sites//', SlugRedirectView.as_view(), kwargs={'model': 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}), diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index b20e1c3d1..6b3565bfb 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,7 +1,6 @@ from django.urls import path from extras.views import ObjectChangeLogView, ObjectJournalView -from utilities.views import SlugRedirectView from . import views from .models import * @@ -26,7 +25,6 @@ urlpatterns = [ path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), path('tenants//', views.TenantView.as_view(), name='tenant'), - path('tenants//', SlugRedirectView.as_view(), kwargs={'model': 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}), diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a3afcb1c6..efea0b867 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -125,14 +125,3 @@ class GetReturnURLMixin: # If all else fails, return home. Ideally this should never happen. return reverse('home') - - -# -# Views -# - -class SlugRedirectView(View): - - def get(self, request, model, slug): - obj = get_object_or_404(model.objects.restrict(request.user, 'view'), slug=slug) - return redirect(obj.get_absolute_url()) From d2d29782886e89e30456d9a21716817214d0949d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Dec 2021 16:20:24 -0500 Subject: [PATCH 004/271] Closes #7748: Remove legacy contact fields from site model --- docs/release-notes/version-3.2.md | 7 +++ netbox/dcim/api/serializers.py | 6 +-- netbox/dcim/filtersets.py | 10 ++-- netbox/dcim/forms/bulk_import.py | 3 +- netbox/dcim/forms/models.py | 9 ++-- .../0144_site_remove_deprecated_fields.py | 23 ++++++++++ netbox/dcim/models/sites.py | 14 +----- netbox/dcim/tables/sites.py | 3 +- netbox/dcim/tests/test_filtersets.py | 18 ++------ netbox/dcim/tests/test_views.py | 3 -- netbox/templates/dcim/site.html | 46 ------------------- 11 files changed, 45 insertions(+), 97 deletions(-) create mode 100644 netbox/dcim/migrations/0144_site_remove_deprecated_fields.py diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 953ac36fe..5a0b266f8 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,8 +8,15 @@ ### Breaking Changes * Automatic redirection of legacy slug-based URL paths has been removed. +* The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please use the new contact model introduced in NetBox v3.1 to store contact information for sites. ### Other Changes * [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later +* [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model * [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs + +### REST API Changes + +* dcim.Site + * Removed the `contact_name`, `contact_phone`, and `contact_email` fields diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 45930c5f5..02c845091 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -133,9 +133,9 @@ class SiteSerializer(PrimaryModelSerializer): model = Site fields = [ 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', + 'rack_count', 'virtualmachine_count', 'vlan_count', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 35d20f44b..134a555f4 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -140,10 +140,9 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class Meta: model = Site - fields = [ - 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', - ] + fields = ( + 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', + ) def search(self, queryset, name, value): if not value.strip(): @@ -154,9 +153,6 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): Q(description__icontains=value) | Q(physical_address__icontains=value) | Q(shipping_address__icontains=value) | - Q(contact_name__icontains=value) | - Q(contact_phone__icontains=value) | - Q(contact_email__icontains=value) | Q(comments__icontains=value) ) try: diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index df8c4ec01..081f8d466 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -96,8 +96,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): model = Site fields = ( 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', ) help_texts = { 'time_zone': mark_safe( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index db2f58a63..309b203b1 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -132,8 +132,8 @@ class SiteForm(TenancyForm, CustomFieldModelForm): model = Site fields = [ 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', + 'tags', ] fieldsets = ( ('Site', ( @@ -141,10 +141,7 @@ class SiteForm(TenancyForm, CustomFieldModelForm): 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), - ('Contact Info', ( - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', - )), + ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')), ) widgets = { 'physical_address': SmallTextarea( diff --git a/netbox/dcim/migrations/0144_site_remove_deprecated_fields.py b/netbox/dcim/migrations/0144_site_remove_deprecated_fields.py new file mode 100644 index 000000000..14554d0a0 --- /dev/null +++ b/netbox/dcim/migrations/0144_site_remove_deprecated_fields.py @@ -0,0 +1,23 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0143_remove_primary_for_related_name'), + ] + + operations = [ + migrations.RemoveField( + model_name='site', + name='contact_email', + ), + migrations.RemoveField( + model_name='site', + name='contact_name', + ), + migrations.RemoveField( + model_name='site', + name='contact_phone', + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a19ae8050..5dd05734c 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -288,18 +288,6 @@ class Site(PrimaryModel): null=True, help_text='GPS coordinate (longitude)' ) - contact_name = models.CharField( - max_length=50, - blank=True - ) - contact_phone = models.CharField( - max_length=20, - blank=True - ) - contact_email = models.EmailField( - blank=True, - verbose_name='Contact E-mail' - ) comments = models.TextField( blank=True ) @@ -320,7 +308,7 @@ class Site(PrimaryModel): clone_fields = [ 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', + 'shipping_address', 'latitude', 'longitude', ] class Meta: diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 8ef17c6f2..ceca41c86 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -97,8 +97,7 @@ class SiteTable(BaseTable): model = Site fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', - 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ab290f791..396355ebb 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -151,9 +151,9 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): ASN.objects.bulk_create(asns) sites = ( - Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), - Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), - Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30), ) Site.objects.bulk_create(sites) sites[0].asns.set([asns[0]]) @@ -189,18 +189,6 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'longitude': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_contact_name(self): - params = {'contact_name': ['Contact 1', 'Contact 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_contact_phone(self): - params = {'contact_phone': ['123-555-0001', '123-555-0002']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_contact_email(self): - params = {'contact_email': ['contact1@example.com', 'contact2@example.com']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_status(self): params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 154ea03c3..4706cdc6a 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -144,9 +144,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'shipping_address': '742 Evergreen Terrace, Springfield, USA', 'latitude': Decimal('35.780000'), 'longitude': Decimal('-78.642000'), - 'contact_name': 'Hank Hill', - 'contact_phone': '123-555-9999', - 'contact_email': 'hank@stricklandpropane.com', 'comments': 'Test site', 'tags': [t.pk for t in tags], } diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 2ad970301..a8d39be40 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -127,52 +127,6 @@ {% endif %} - {# Legacy contact fields #} - {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %} - {% if object.contact_name %} - - Contact Name - - {% if object.contact_name %} -
- -
- {% endif %} - {{ object.contact_name|placeholder }} - - - {% endif %} - {% if object.contact_phone %} - - Contact Phone - - {% if object.contact_phone %} -
- -
- {{ object.contact_phone }} - {% else %} - - {% endif %} - - - {% endif %} - {% if object.contact_email %} - - Contact E-Mail - - {% if object.contact_email %} -
- -
- {{ object.contact_email }} - {% else %} - - {% endif %} - - - {% endif %} - {% endwith %} From 7d99e15dc30c8637b7cc04b153cd4ad95d25f001 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Dec 2021 16:56:22 -0500 Subject: [PATCH 005/271] Closes #7743: Remove legacy ASN field from site model --- docs/release-notes/version-3.2.md | 5 +- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/filtersets.py | 8 +- netbox/dcim/forms/bulk_edit.py | 8 +- netbox/dcim/forms/models.py | 13 ++- .../0144_site_remove_deprecated_fields.py | 4 + netbox/dcim/models/sites.py | 8 +- netbox/dcim/tests/test_filtersets.py | 8 +- netbox/extras/tests/test_customvalidator.py | 17 ++-- netbox/templates/dcim/site.html | 4 - netbox/utilities/tests/test_filters.py | 89 +++++++++---------- 11 files changed, 80 insertions(+), 86 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 5a0b266f8..a92968bc9 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,15 +8,18 @@ ### Breaking Changes * Automatic redirection of legacy slug-based URL paths has been removed. +* The `asn` field has been removed from the site model. Please use the ASN model introduced in NetBox v3.1 to track ASN assignments for sites. +* The `asn` query filter for sites now matches against the AS number of assigned ASNs. * The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please use the new contact model introduced in NetBox v3.1 to store contact information for sites. ### Other Changes * [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later +* [#7743](https://github.com/netbox-community/netbox/issues/7743) - Remove legacy ASN field from site model * [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model * [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs ### REST API Changes * dcim.Site - * Removed the `contact_name`, `contact_phone`, and `contact_email` fields + * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 02c845091..113c71745 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -132,7 +132,7 @@ class SiteSerializer(PrimaryModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asns', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 134a555f4..990c55115 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -131,6 +131,12 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Group (slug)', ) + asn = django_filters.ModelMultipleChoiceFilter( + field_name='asns__asn', + queryset=ASN.objects.all(), + to_field_name='asn', + label='AS (ID)', + ) asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), @@ -141,7 +147,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class Meta: model = Site fields = ( - 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', + 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', ) def search(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9127b072f..a40396e98 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -111,12 +111,6 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - asn = forms.IntegerField( - min_value=BGP_ASN_MIN, - max_value=BGP_ASN_MAX, - required=False, - label='ASN' - ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), @@ -134,7 +128,7 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): class Meta: nullable_fields = [ - 'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', ] diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 309b203b1..d16cf3dd1 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -130,15 +130,13 @@ class SiteForm(TenancyForm, CustomFieldModelForm): class Meta: model = Site - fields = [ - 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', - 'tags', - ] + fields = ( + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', + ) fieldsets = ( ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'asns', 'time_zone', 'description', - 'tags', + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')), @@ -159,7 +157,6 @@ class SiteForm(TenancyForm, CustomFieldModelForm): } help_texts = { 'name': "Full name of the site", - 'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model", 'facility': "Data center provider and facility (e.g. Equinix NY7)", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", diff --git a/netbox/dcim/migrations/0144_site_remove_deprecated_fields.py b/netbox/dcim/migrations/0144_site_remove_deprecated_fields.py index 14554d0a0..1dcf4d43c 100644 --- a/netbox/dcim/migrations/0144_site_remove_deprecated_fields.py +++ b/netbox/dcim/migrations/0144_site_remove_deprecated_fields.py @@ -8,6 +8,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RemoveField( + model_name='site', + name='asn', + ), migrations.RemoveField( model_name='site', name='contact_email', diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 5dd05734c..0be7e4617 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -248,12 +248,6 @@ class Site(PrimaryModel): blank=True, help_text='Local facility ID or description' ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) asns = models.ManyToManyField( to='ipam.ASN', related_name='sites', @@ -307,7 +301,7 @@ class Site(PrimaryModel): ) clone_fields = [ - 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', + 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 396355ebb..a187c8881 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -151,9 +151,9 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): ASN.objects.bulk_create(asns) sites = ( - Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10), - Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20), - Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', latitude=10, longitude=10), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', latitude=20, longitude=20), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', latitude=30, longitude=30), ) Site.objects.bulk_create(sites) sites[0].asns.set([asns[0]]) @@ -173,7 +173,7 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asn(self): - params = {'asn': [65001, 65002]} + params = {'asn': ['64512', '64513']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asn_id(self): diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidator.py index 89857b615..ce3b572d1 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidator.py @@ -2,6 +2,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from circuits.models import Provider from dcim.models import Site from extras.validators import CustomValidator @@ -66,26 +67,26 @@ custom_validator = MyValidator() class CustomValidatorTest(TestCase): - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]}) + @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) def test_configuration(self): - self.assertIn('dcim.site', settings.CUSTOM_VALIDATORS) - validator = settings.CUSTOM_VALIDATORS['dcim.site'][0] + self.assertIn('circuits.provider', settings.CUSTOM_VALIDATORS) + validator = settings.CUSTOM_VALIDATORS['circuits.provider'][0] self.assertIsInstance(validator, CustomValidator) - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]}) + @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) def test_min(self): with self.assertRaises(ValidationError): - Site(name='abcdef123', slug='abcdefghijk', asn=1).clean() + Provider(name='Provider 1', slug='provider-1', asn=1).clean() - @override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_validator]}) + @override_settings(CUSTOM_VALIDATORS={'circuits.provider': [max_validator]}) def test_max(self): with self.assertRaises(ValidationError): - Site(name='abcdef123', slug='abcdefghijk', asn=65535).clean() + Provider(name='Provider 1', slug='provider-1', asn=65535).clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) def test_min_length(self): with self.assertRaises(ValidationError): - Site(name='abc', slug='abc', asn=65000).clean() + Site(name='abc', slug='abc').clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_length_validator]}) def test_max_length(self): diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index a8d39be40..539974b86 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -78,10 +78,6 @@ Description {{ object.description|placeholder }} - - AS Number - {{ object.asn|placeholder }} - Time Zone diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 2616dbf36..5182722d1 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,9 +5,8 @@ from django.test import TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager -from circuits.choices import CircuitStatusChoices -from circuits.filtersets import CircuitFilterSet -from circuits.models import Circuit, Provider, CircuitType +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 @@ -337,16 +336,16 @@ class DynamicFilterLookupExpressionTest(TestCase): """ Validate function of automatically generated filters using the Device model as an example. """ - device_queryset = Device.objects.all() - device_filterset = DeviceFilterSet - site_queryset = Site.objects.all() - site_filterset = SiteFilterSet - circuit_queryset = Circuit.objects.all() - circuit_filterset = CircuitFilterSet - @classmethod def setUpTestData(cls): + 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), + ) + Provider.objects.bulk_create(providers) + manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -384,9 +383,9 @@ class DynamicFilterLookupExpressionTest(TestCase): region.save() sites = ( - Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), - Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), - Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), + Site(name='Site 1', slug='abc-site-1', region=regions[0]), + Site(name='Site 2', slug='def-site-2', region=regions[1]), + Site(name='Site 3', slug='ghi-site-3', region=regions[2]), ) Site.objects.bulk_create(sites) @@ -429,112 +428,112 @@ class DynamicFilterLookupExpressionTest(TestCase): def test_site_name_negation(self): params = {'name__n': ['Site 1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_slug_icontains(self): params = {'slug__ic': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1) def test_site_slug_icontains_negation(self): params = {'slug__nic': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_slug_startswith(self): params = {'slug__isw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1) def test_site_slug_startswith_negation(self): params = {'slug__nisw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_slug_endswith(self): params = {'slug__iew': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1) def test_site_slug_endswith_negation(self): params = {'slug__niew': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) - def test_site_asn_lt(self): + def test_provider_asn_lt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) - def test_site_asn_lte(self): + def test_provider_asn_lte(self): params = {'asn__lte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) - def test_site_asn_gt(self): + def test_provider_asn_gt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) - def test_site_asn_gte(self): + def test_provider_asn_gte(self): params = {'asn__gte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_region_id_negation(self): params = {'region_id__n': [Region.objects.first().pk]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_device_name_eq(self): params = {'name': ['Device 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_name_negation(self): params = {'name__n': ['Device 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_name_startswith(self): params = {'name__isw': ['Device']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 3) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 3) def test_device_name_startswith_negation(self): params = {'name__nisw': ['Device 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_name_endswith(self): params = {'name__iew': [' 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_name_endswith_negation(self): params = {'name__niew': [' 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_name_icontains(self): params = {'name__ic': [' 2']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_name_icontains_negation(self): params = {'name__nic': [' ']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 0) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 0) def test_device_mac_address_negation(self): params = {'mac_address__n': ['00-00-00-00-00-01', 'aa-00-00-00-00-01']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_startswith(self): params = {'mac_address__isw': ['aa:']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_mac_address_startswith_negation(self): params = {'mac_address__nisw': ['aa:']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_endswith(self): params = {'mac_address__iew': [':02']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_mac_address_endswith_negation(self): params = {'mac_address__niew': [':02']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_icontains(self): params = {'mac_address__ic': ['aa:', 'bb']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_icontains_negation(self): params = {'mac_address__nic': ['aa:', 'bb']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) From 2dad35186a62524a12f0641d79c9360b05f504d9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 14 Dec 2021 11:28:13 -0500 Subject: [PATCH 006/271] Generic view cleanup --- netbox/circuits/views.py | 2 +- netbox/dcim/views.py | 13 ++-- netbox/extras/views.py | 4 +- netbox/ipam/views.py | 6 +- netbox/netbox/views/generic.py | 126 +++++++++++++++++++++++---------- netbox/tenancy/views.py | 2 +- 6 files changed, 102 insertions(+), 51 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b549b3a01..2f1addab1 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -320,7 +320,7 @@ class CircuitTerminationEditView(generic.ObjectEditView): model_form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' - def alter_obj(self, obj, request, url_args, url_kwargs): + def alter_object(self, obj, request, url_args, url_kwargs): if 'circuit' in url_kwargs: obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit']) return obj diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b1a53e93c..6fb3e2a00 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -671,7 +671,7 @@ class RackReservationEditView(generic.ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm - def alter_obj(self, obj, request, args, kwargs): + def alter_object(self, obj, request, args, kwargs): if not obj.pk: if 'rack' in request.GET: obj.rack = get_object_or_404(Rack, pk=request.GET.get('rack')) @@ -2342,7 +2342,7 @@ class CableCreateView(generic.ObjectEditView): return super().dispatch(request, *args, **kwargs) - def alter_obj(self, obj, request, url_args, url_kwargs): + def alter_object(self, obj, request, url_args, url_kwargs): termination_a_type = url_kwargs.get('termination_a_type') termination_a_id = url_kwargs.get('termination_a_id') termination_b_type_name = url_kwargs.get('termination_b_type') @@ -2355,7 +2355,8 @@ class CableCreateView(generic.ObjectEditView): return obj def get(self, request, *args, **kwargs): - obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + obj = self.get_object(**kwargs) + obj = self.alter_object(obj, request, args, kwargs) # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} @@ -2423,7 +2424,7 @@ class ConsoleConnectionsListView(generic.ObjectListView): template_name = 'dcim/connections_list.html' action_buttons = ('export',) - def extra_context(self): + def get_extra_context(self, request): return { 'title': 'Console Connections' } @@ -2437,7 +2438,7 @@ class PowerConnectionsListView(generic.ObjectListView): template_name = 'dcim/connections_list.html' action_buttons = ('export',) - def extra_context(self): + def get_extra_context(self, request): return { 'title': 'Power Connections' } @@ -2451,7 +2452,7 @@ class InterfaceConnectionsListView(generic.ObjectListView): template_name = 'dcim/connections_list.html' action_buttons = ('export',) - def extra_context(self): + def get_extra_context(self, request): return { 'title': 'Interface Connections' } diff --git a/netbox/extras/views.py b/netbox/extras/views.py index ab9e3ba52..4382d1fbb 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -472,7 +472,7 @@ class ImageAttachmentEditView(generic.ObjectEditView): queryset = ImageAttachment.objects.all() model_form = forms.ImageAttachmentForm - def alter_obj(self, instance, request, args, kwargs): + def alter_object(self, instance, request, args, kwargs): if not instance.pk: # Assign the parent object based on URL kwargs content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) @@ -510,7 +510,7 @@ class JournalEntryEditView(generic.ObjectEditView): queryset = JournalEntry.objects.all() model_form = forms.JournalEntryForm - def alter_obj(self, obj, request, args, kwargs): + def alter_object(self, obj, request, args, kwargs): if not obj.pk: obj.created_by = request.user return obj diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c172caf0b..6d3963599 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -721,7 +721,7 @@ class IPAddressEditView(generic.ObjectEditView): model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' - def alter_obj(self, obj, request, url_args, url_kwargs): + def alter_object(self, obj, request, url_args, url_kwargs): if 'interface' in request.GET: try: @@ -975,7 +975,7 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView): model_form = forms.FHRPGroupAssignmentForm template_name = 'ipam/fhrpgroupassignment_edit.html' - def alter_obj(self, instance, request, args, kwargs): + def alter_object(self, instance, request, args, kwargs): if not instance.pk: # Assign the interface based on URL kwargs content_type = get_object_or_404(ContentType, pk=request.GET.get('interface_type')) @@ -1092,7 +1092,7 @@ class ServiceEditView(generic.ObjectEditView): model_form = forms.ServiceForm template_name = 'ipam/service_edit.html' - def alter_obj(self, obj, request, url_args, url_kwargs): + def alter_object(self, obj, request, url_args, url_kwargs): if 'device' in url_kwargs: obj.device = get_object_or_404( Device.objects.restrict(request.user), diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 1c2ff9917..83eabdb23 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -55,14 +55,16 @@ class ObjectView(ObjectPermissionRequiredMixin, View): """ Return any additional context data for the template. - request: The current request - instance: The object being viewed + :param request: The current request + :param instance: The object being viewed """ return {} def get(self, request, *args, **kwargs): """ - Generic GET handler for accessing an object by PK or slug + GET request handler. *args and **kwargs are passed to identify the object being queried. + + :param request: The current request """ instance = get_object_or_404(self.queryset, **kwargs) @@ -78,10 +80,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): queryset: The queryset of objects to display. Note: Prefetching related objects is not necessary, as the table will prefetch objects as needed depending on the columns being displayed. - filter: A django-filter FilterSet that is applied to the queryset - filter_form: The form used to render filter options + filterset: A django-filter FilterSet that is applied to the queryset + filterset_form: The form used to render filter options table: The django-tables2 Table used to render the objects list template_name: The name of the template + action_buttons: A list of buttons to include at the top of the page """ queryset = None filterset = None @@ -94,6 +97,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): return get_permission_for_model(self.queryset.model, 'view') def get_table(self, request, permissions): + """ + Return the django-tables2 Table instance to be used for rendering the objects list. + + :param request: The current request + :param permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating + whether the user has each + """ table = self.table(self.queryset, user=request.user) if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') @@ -143,7 +153,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") return redirect(request.path) + def get_extra_context(self, request): + """ + Return any additional context data for the template. + + :param request: The current request + """ + return {} + def get(self, request): + """ + GET request handler. + + :param request: The current request + """ model = self.queryset.model content_type = ContentType.objects.get_for_model(model) @@ -192,19 +215,16 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): 'action_buttons': self.action_buttons, 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, } - context.update(self.extra_context()) + context.update(self.get_extra_context(request)) return render(request, self.template_name, context) - def extra_context(self): - return {} - class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create or edit a single object. - queryset: The base queryset for the object being modified + queryset: The base QuerySet for the object being modified model_form: The form used to create or edit the object template_name: The name of the template """ @@ -217,25 +237,31 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # we are modifying an existing object or creating a new one. return get_permission_for_model(self.queryset.model, self._permission_action) - def get_object(self, kwargs): - # Look up an existing object by slug or PK, if provided. - if 'slug' in kwargs: - obj = get_object_or_404(self.queryset, slug=kwargs['slug']) - elif 'pk' in kwargs: - obj = get_object_or_404(self.queryset, pk=kwargs['pk']) - # Otherwise, return a new instance. - else: - return self.queryset.model() + def get_object(self, **kwargs): + """ + Return an instance for editing. If a PK has been specified, this will be an existing object. - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() + :param kwargs: URL path kwargs + """ + if 'pk' in kwargs: + obj = get_object_or_404(self.queryset, **kwargs) + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + return obj - return obj + return self.queryset.model() - def alter_obj(self, obj, request, url_args, url_kwargs): - # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined - # given some parameter from the request URL. + def alter_object(self, obj, request, url_args, url_kwargs): + """ + Provides a hook for views to modify an object before it is processed. For example, a parent object can be + defined given some parameter from the request URL. + + :param obj: The object being edited + :param request: The current request + :param url_args: URL path args + :param url_kwargs: URL path kwargs + """ return obj def dispatch(self, request, *args, **kwargs): @@ -245,7 +271,13 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + """ + GET request handler. + + :param request: The current request + """ + obj = self.get_object(**kwargs) + obj = self.alter_object(obj, request, args, kwargs) initial_data = normalize_querydict(request.GET) form = self.model_form(instance=obj, initial=initial_data) @@ -259,8 +291,15 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) def post(self, request, *args, **kwargs): + """ + POST request handler. + + :param request: The current request + """ logger = logging.getLogger('netbox.views.ObjectEditView') - obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + obj = self.get_object(**kwargs) + obj = self.alter_object(obj, request, args, kwargs) + form = self.model_form( data=request.POST, files=request.FILES, @@ -334,12 +373,13 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') - def get_object(self, kwargs): - # Look up object by slug if one has been provided. Otherwise, use PK. - if 'slug' in kwargs: - obj = get_object_or_404(self.queryset, slug=kwargs['slug']) - else: - obj = get_object_or_404(self.queryset, pk=kwargs['pk']) + def get_object(self, **kwargs): + """ + Return an instance for deletion. If a PK has been specified, this will be an existing object. + + :param kwargs: URL path kwargs + """ + obj = get_object_or_404(self.queryset, **kwargs) # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): @@ -347,8 +387,13 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return obj - def get(self, request, **kwargs): - obj = self.get_object(kwargs) + def get(self, request, *args, **kwargs): + """ + GET request handler. + + :param request: The current request + """ + obj = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) return render(request, self.template_name, { @@ -358,9 +403,14 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'return_url': self.get_return_url(request, obj), }) - def post(self, request, **kwargs): + def post(self, request, *args, **kwargs): + """ + POST request handler. + + :param request: The current request + """ logger = logging.getLogger('netbox.views.ObjectDeleteView') - obj = self.get_object(kwargs) + obj = self.get_object(**kwargs) form = ConfirmationForm(request.POST) if form.is_valid(): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index c848de47f..b41af62ee 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -353,7 +353,7 @@ class ContactAssignmentEditView(generic.ObjectEditView): model_form = forms.ContactAssignmentForm template_name = 'tenancy/contactassignment_edit.html' - def alter_obj(self, instance, request, args, kwargs): + def alter_object(self, instance, request, args, kwargs): if not instance.pk: # Assign the object based on URL kwargs content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) From 419f86a4a5efbfa18c5cf8cb722d8c4bc2c29fbc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 09:36:15 -0500 Subject: [PATCH 007/271] #8054: Support configurable status choices --- docs/configuration/optional-settings.md | 35 +++++++++++++++++++++++++ netbox/circuits/choices.py | 5 ++-- netbox/dcim/choices.py | 20 ++++++++------ netbox/ipam/choices.py | 20 ++++++++------ netbox/netbox/settings.py | 1 + netbox/utilities/choices.py | 14 ++++++++++ netbox/virtualization/choices.py | 5 ++-- 7 files changed, 80 insertions(+), 20 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index d8d79b6ec..97c1b6201 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -140,6 +140,41 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- +## FIELD_CHOICES + +Default: Empty dictionary + +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices list. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. + +For example, to specify a custom set of choices for the site status field: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo'), + ('bar', 'Bar'), + ('baz', 'Baz'), + ) +} +``` + +These will be appended to the stock choices for the field. + +The following model field support configurable choices: + +* `circuits.Circuit.status` +* `dcim.Device.status` +* `dcim.PowerFeed.status` +* `dcim.Rack.status` +* `dcim.Site.status` +* `ipam.IPAddress.status` +* `ipam.IPRange.status` +* `ipam.Prefix.status` +* `ipam.VLAN.status` +* `virtualization.VirtualMachine.status` + +--- + ## HTTP_PROXIES Default: None diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 0efa431fa..e3177adb4 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class CircuitStatusChoices(ChoiceSet): + key = 'circuits.Circuit.status' STATUS_DEPROVISIONING = 'deprovisioning' STATUS_ACTIVE = 'active' @@ -14,14 +15,14 @@ class CircuitStatusChoices(ChoiceSet): STATUS_OFFLINE = 'offline' STATUS_DECOMMISSIONED = 'decommissioned' - CHOICES = ( + CHOICES = [ (STATUS_PLANNED, 'Planned'), (STATUS_PROVISIONING, 'Provisioning'), (STATUS_ACTIVE, 'Active'), (STATUS_OFFLINE, 'Offline'), (STATUS_DEPROVISIONING, 'Deprovisioning'), (STATUS_DECOMMISSIONED, 'Decommissioned'), - ) + ] CSS_CLASSES = { STATUS_DEPROVISIONING: 'warning', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fcb37211f..208a06c5d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class SiteStatusChoices(ChoiceSet): + key = 'dcim.Site.status' STATUS_PLANNED = 'planned' STATUS_STAGING = 'staging' @@ -13,13 +14,13 @@ class SiteStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' - CHOICES = ( + CHOICES = [ (STATUS_PLANNED, 'Planned'), (STATUS_STAGING, 'Staging'), (STATUS_ACTIVE, 'Active'), (STATUS_DECOMMISSIONING, 'Decommissioning'), (STATUS_RETIRED, 'Retired'), - ) + ] CSS_CLASSES = { STATUS_PLANNED: 'info', @@ -67,6 +68,7 @@ class RackWidthChoices(ChoiceSet): class RackStatusChoices(ChoiceSet): + key = 'dcim.Rack.status' STATUS_RESERVED = 'reserved' STATUS_AVAILABLE = 'available' @@ -74,13 +76,13 @@ class RackStatusChoices(ChoiceSet): STATUS_ACTIVE = 'active' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_RESERVED, 'Reserved'), (STATUS_AVAILABLE, 'Available'), (STATUS_PLANNED, 'Planned'), (STATUS_ACTIVE, 'Active'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_RESERVED: 'warning', @@ -144,6 +146,7 @@ class DeviceFaceChoices(ChoiceSet): class DeviceStatusChoices(ChoiceSet): + key = 'dcim.Device.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -153,7 +156,7 @@ class DeviceStatusChoices(ChoiceSet): STATUS_INVENTORY = 'inventory' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( + CHOICES = [ (STATUS_OFFLINE, 'Offline'), (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), @@ -161,7 +164,7 @@ class DeviceStatusChoices(ChoiceSet): (STATUS_FAILED, 'Failed'), (STATUS_INVENTORY, 'Inventory'), (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) + ] CSS_CLASSES = { STATUS_OFFLINE: 'warning', @@ -1183,18 +1186,19 @@ class CableLengthUnitChoices(ChoiceSet): # class PowerFeedStatusChoices(ChoiceSet): + key = 'dcim.PowerFeed.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' STATUS_FAILED = 'failed' - CHOICES = ( + CHOICES = [ (STATUS_OFFLINE, 'Offline'), (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), (STATUS_FAILED, 'Failed'), - ) + ] CSS_CLASSES = { STATUS_OFFLINE: 'warning', diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 638ef62f6..c414fc115 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -17,18 +17,19 @@ class IPAddressFamilyChoices(ChoiceSet): # class PrefixStatusChoices(ChoiceSet): + key = 'ipam.Prefix.status' STATUS_CONTAINER = 'container' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_CONTAINER, 'Container'), (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_CONTAINER: 'secondary', @@ -43,16 +44,17 @@ class PrefixStatusChoices(ChoiceSet): # class IPRangeStatusChoices(ChoiceSet): + key = 'ipam.IPRange.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_ACTIVE: 'primary', @@ -66,6 +68,7 @@ class IPRangeStatusChoices(ChoiceSet): # class IPAddressStatusChoices(ChoiceSet): + key = 'ipam.IPAddress.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' @@ -73,13 +76,13 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_DHCP = 'dhcp' STATUS_SLAAC = 'slaac' - CHOICES = ( + CHOICES = [ (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), (STATUS_DHCP, 'DHCP'), (STATUS_SLAAC, 'SLAAC'), - ) + ] CSS_CLASSES = { STATUS_ACTIVE: 'primary', @@ -161,16 +164,17 @@ class FHRPGroupAuthTypeChoices(ChoiceSet): # class VLANStatusChoices(ChoiceSet): + key = 'ipam.VLAN.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_ACTIVE: 'primary', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9bc0dbc0c..9d956250b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -86,6 +86,7 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) +FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index b831b3490..46d74490a 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,7 +1,21 @@ +from django.conf import settings + + class ChoiceSetMeta(type): """ Metaclass for ChoiceSet """ + def __new__(mcs, name, bases, attrs): + + # Extend static choices with any configured choices + if 'key' in attrs: + try: + attrs['CHOICES'].extend(settings.FIELD_CHOICES[attrs['key']]) + except KeyError: + pass + + return super().__new__(mcs, name, bases, attrs) + def __call__(cls, *args, **kwargs): # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable return getattr(cls, 'CHOICES', ()) diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 9c4eb6cd5..c121d052e 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class VirtualMachineStatusChoices(ChoiceSet): + key = 'virtualization.VirtualMachine.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -14,14 +15,14 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_FAILED = 'failed' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( + CHOICES = [ (STATUS_OFFLINE, 'Offline'), (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), (STATUS_STAGED, 'Staged'), (STATUS_FAILED, 'Failed'), (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) + ] CSS_CLASSES = { STATUS_OFFLINE: 'warning', From 0d3b50a5e50ad218bfac1ede0c8559682cb7b384 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:03:23 -0500 Subject: [PATCH 008/271] Support CSS class definition directly in CHOICES iterable --- netbox/circuits/choices.py | 21 ++---- netbox/circuits/models/circuits.py | 2 +- netbox/dcim/choices.py | 96 +++++++----------------- netbox/dcim/models/cables.py | 2 +- netbox/dcim/models/devices.py | 2 +- netbox/dcim/models/power.py | 4 +- netbox/dcim/models/racks.py | 2 +- netbox/dcim/models/sites.py | 2 +- netbox/extras/choices.py | 45 +++-------- netbox/extras/models/change_logging.py | 2 +- netbox/extras/models/models.py | 2 +- netbox/extras/templatetags/log_levels.py | 2 +- netbox/ipam/choices.py | 82 ++++++-------------- netbox/ipam/models/ip.py | 8 +- netbox/ipam/models/vlans.py | 2 +- netbox/utilities/choices.py | 25 +++++- netbox/virtualization/choices.py | 21 ++---- netbox/virtualization/models.py | 2 +- netbox/wireless/models.py | 2 +- 19 files changed, 110 insertions(+), 214 deletions(-) diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index e3177adb4..007b45298 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -16,23 +16,14 @@ class CircuitStatusChoices(ChoiceSet): STATUS_DECOMMISSIONED = 'decommissioned' CHOICES = [ - (STATUS_PLANNED, 'Planned'), - (STATUS_PROVISIONING, 'Provisioning'), - (STATUS_ACTIVE, 'Active'), - (STATUS_OFFLINE, 'Offline'), - (STATUS_DEPROVISIONING, 'Deprovisioning'), - (STATUS_DECOMMISSIONED, 'Decommissioned'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_PROVISIONING, 'Provisioning', 'primary'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_OFFLINE, 'Offline', 'danger'), + (STATUS_DEPROVISIONING, 'Deprovisioning', 'warning'), + (STATUS_DECOMMISSIONED, 'Decommissioned', 'secondary'), ] - CSS_CLASSES = { - STATUS_DEPROVISIONING: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_PROVISIONING: 'primary', - STATUS_OFFLINE: 'danger', - STATUS_DECOMMISSIONED: 'secondary', - } - # # CircuitTerminations diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 602c0f403..013aef557 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -135,7 +135,7 @@ class Circuit(PrimaryModel): return reverse('circuits:circuit', args=[self.pk]) def get_status_class(self): - return CircuitStatusChoices.CSS_CLASSES.get(self.status) + return CircuitStatusChoices.colors.get(self.status, 'secondary') @extras_features('webhooks') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 208a06c5d..bcc926580 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -15,21 +15,13 @@ class SiteStatusChoices(ChoiceSet): STATUS_RETIRED = 'retired' CHOICES = [ - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGING, 'Staging'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - (STATUS_RETIRED, 'Retired'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGING, 'Staging', 'primary'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), + (STATUS_RETIRED, 'Retired', 'danger'), ] - CSS_CLASSES = { - STATUS_PLANNED: 'info', - STATUS_STAGING: 'primary', - STATUS_ACTIVE: 'success', - STATUS_DECOMMISSIONING: 'warning', - STATUS_RETIRED: 'danger', - } - # # Racks @@ -77,21 +69,13 @@ class RackStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_RESERVED, 'Reserved'), - (STATUS_AVAILABLE, 'Available'), - (STATUS_PLANNED, 'Planned'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_RESERVED, 'Reserved', 'warning'), + (STATUS_AVAILABLE, 'Available', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_RESERVED: 'warning', - STATUS_AVAILABLE: 'success', - STATUS_PLANNED: 'info', - STATUS_ACTIVE: 'primary', - STATUS_DEPRECATED: 'danger', - } - class RackDimensionUnitChoices(ChoiceSet): @@ -157,25 +141,15 @@ class DeviceStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = [ - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_INVENTORY, 'Inventory'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGED, 'Staged', 'primary'), + (STATUS_FAILED, 'Failed', 'danger'), + (STATUS_INVENTORY, 'Inventory', 'secondary'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), ] - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_INVENTORY: 'secondary', - STATUS_DECOMMISSIONING: 'warning', - } - class DeviceAirflowChoices(ChoiceSet): @@ -1147,17 +1121,11 @@ class LinkStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( - (STATUS_CONNECTED, 'Connected'), - (STATUS_PLANNED, 'Planned'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_CONNECTED, 'Connected', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), ) - CSS_CLASSES = { - STATUS_CONNECTED: 'success', - STATUS_PLANNED: 'info', - STATUS_DECOMMISSIONING: 'warning', - } - class CableLengthUnitChoices(ChoiceSet): @@ -1194,19 +1162,12 @@ class PowerFeedStatusChoices(ChoiceSet): STATUS_FAILED = 'failed' CHOICES = [ - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_FAILED, 'Failed'), + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_FAILED, 'Failed', 'danger'), ] - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_FAILED: 'danger', - } - class PowerFeedTypeChoices(ChoiceSet): @@ -1214,15 +1175,10 @@ class PowerFeedTypeChoices(ChoiceSet): TYPE_REDUNDANT = 'redundant' CHOICES = ( - (TYPE_PRIMARY, 'Primary'), - (TYPE_REDUNDANT, 'Redundant'), + (TYPE_PRIMARY, 'Primary', 'success'), + (TYPE_REDUNDANT, 'Redundant', 'info'), ) - CSS_CLASSES = { - TYPE_PRIMARY: 'success', - TYPE_REDUNDANT: 'info', - } - class PowerFeedSupplyChoices(ChoiceSet): diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 333972b21..12fe91036 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -289,7 +289,7 @@ class Cable(PrimaryModel): self._pk = self.pk def get_status_class(self): - return LinkStatusChoices.CSS_CLASSES.get(self.status) + return LinkStatusChoices.colors.get(self.status) def get_compatible_types(self): """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index a2ae20319..24eeb7ac3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -862,7 +862,7 @@ class Device(PrimaryModel, ConfigContextModel): return Device.objects.filter(parent_bay__device=self.pk) def get_status_class(self): - return DeviceStatusChoices.CSS_CLASSES.get(self.status) + return DeviceStatusChoices.colors.get(self.status, 'secondary') # diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index b5d8d4c83..e3146c167 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -174,7 +174,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): return self.power_panel def get_type_class(self): - return PowerFeedTypeChoices.CSS_CLASSES.get(self.type) + return PowerFeedTypeChoices.colors.get(self.type) def get_status_class(self): - return PowerFeedStatusChoices.CSS_CLASSES.get(self.status) + return PowerFeedStatusChoices.colors.get(self.status, 'secondary') diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 082ecfe57..c324d4cba 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -251,7 +251,7 @@ class Rack(PrimaryModel): return reversed(range(1, self.u_height + 1)) def get_status_class(self): - return RackStatusChoices.CSS_CLASSES.get(self.status) + return RackStatusChoices.colors.get(self.status, 'secondary') def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 0be7e4617..a71206224 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -315,7 +315,7 @@ class Site(PrimaryModel): return reverse('dcim:site', args=[self.pk]) def get_status_class(self): - return SiteStatusChoices.CSS_CLASSES.get(self.status) + return SiteStatusChoices.colors.get(self.status, 'secondary') # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 7503b4110..ff117c4e5 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -91,17 +91,11 @@ class ObjectChangeActionChoices(ChoiceSet): ACTION_DELETE = 'delete' CHOICES = ( - (ACTION_CREATE, 'Created'), - (ACTION_UPDATE, 'Updated'), - (ACTION_DELETE, 'Deleted'), + (ACTION_CREATE, 'Created', 'success'), + (ACTION_UPDATE, 'Updated', 'primary'), + (ACTION_DELETE, 'Deleted', 'danger'), ) - CSS_CLASSES = { - ACTION_CREATE: 'success', - ACTION_UPDATE: 'primary', - ACTION_DELETE: 'danger', - } - # # Jounral entries @@ -115,19 +109,12 @@ class JournalEntryKindChoices(ChoiceSet): KIND_DANGER = 'danger' CHOICES = ( - (KIND_INFO, 'Info'), - (KIND_SUCCESS, 'Success'), - (KIND_WARNING, 'Warning'), - (KIND_DANGER, 'Danger'), + (KIND_INFO, 'Info', 'info'), + (KIND_SUCCESS, 'Success', 'success'), + (KIND_WARNING, 'Warning', 'warning'), + (KIND_DANGER, 'Danger', 'danger'), ) - CSS_CLASSES = { - KIND_INFO: 'info', - KIND_SUCCESS: 'success', - KIND_WARNING: 'warning', - KIND_DANGER: 'danger', - } - # # Log Levels for Reports and Scripts @@ -142,21 +129,13 @@ class LogLevelChoices(ChoiceSet): LOG_FAILURE = 'failure' CHOICES = ( - (LOG_DEFAULT, 'Default'), - (LOG_SUCCESS, 'Success'), - (LOG_INFO, 'Info'), - (LOG_WARNING, 'Warning'), - (LOG_FAILURE, 'Failure'), + (LOG_DEFAULT, 'Default', 'secondary'), + (LOG_SUCCESS, 'Success', 'success'), + (LOG_INFO, 'Info', 'info'), + (LOG_WARNING, 'Warning', 'warning'), + (LOG_FAILURE, 'Failure', 'danger'), ) - CSS_CLASSES = { - LOG_DEFAULT: 'secondary', - LOG_SUCCESS: 'success', - LOG_INFO: 'info', - LOG_WARNING: 'warning', - LOG_FAILURE: 'danger', - } - # # Job results diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 15bd3cbd8..8dfeb2f18 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -105,4 +105,4 @@ class ObjectChange(BigIDModel): return reverse('extras:objectchange', args=[self.pk]) def get_action_class(self): - return ObjectChangeActionChoices.CSS_CLASSES.get(self.action) + return ObjectChangeActionChoices.colors.get(self.action) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 47da21e19..c20117b91 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -440,7 +440,7 @@ class JournalEntry(ChangeLoggedModel): return reverse('extras:journalentry', args=[self.pk]) def get_kind_class(self): - return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) + return JournalEntryKindChoices.colors.get(self.kind) class JobResult(BigIDModel): diff --git a/netbox/extras/templatetags/log_levels.py b/netbox/extras/templatetags/log_levels.py index 050a6996d..0779a87eb 100644 --- a/netbox/extras/templatetags/log_levels.py +++ b/netbox/extras/templatetags/log_levels.py @@ -13,5 +13,5 @@ def log_level(level): """ return { 'name': LogLevelChoices.as_dict()[level], - 'class': LogLevelChoices.CSS_CLASSES.get(level) + 'class': LogLevelChoices.colors.get(level) } diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index c414fc115..693ee6689 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -25,19 +25,12 @@ class PrefixStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_CONTAINER, 'Container'), - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_CONTAINER, 'Container', 'secondary'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_CONTAINER: 'secondary', - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } - # # IP Ranges @@ -51,17 +44,11 @@ class IPRangeStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } - # # IP Addresses @@ -77,21 +64,13 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_SLAAC = 'slaac' CHOICES = [ - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), - (STATUS_DHCP, 'DHCP'), - (STATUS_SLAAC, 'SLAAC'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), + (STATUS_DHCP, 'DHCP', 'success'), + (STATUS_SLAAC, 'SLAAC', 'success'), ] - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - STATUS_DHCP: 'success', - STATUS_SLAAC: 'success', - } - class IPAddressRoleChoices(ChoiceSet): @@ -105,27 +84,16 @@ class IPAddressRoleChoices(ChoiceSet): ROLE_CARP = 'carp' CHOICES = ( - (ROLE_LOOPBACK, 'Loopback'), - (ROLE_SECONDARY, 'Secondary'), - (ROLE_ANYCAST, 'Anycast'), + (ROLE_LOOPBACK, 'Loopback', 'secondary'), + (ROLE_SECONDARY, 'Secondary', 'primary'), + (ROLE_ANYCAST, 'Anycast', 'warning'), (ROLE_VIP, 'VIP'), - (ROLE_VRRP, 'VRRP'), - (ROLE_HSRP, 'HSRP'), - (ROLE_GLBP, 'GLBP'), - (ROLE_CARP, 'CARP'), + (ROLE_VRRP, 'VRRP', 'success'), + (ROLE_HSRP, 'HSRP', 'success'), + (ROLE_GLBP, 'GLBP', 'success'), + (ROLE_CARP, 'CARP'), 'success', ) - CSS_CLASSES = { - ROLE_LOOPBACK: 'secondary', - ROLE_SECONDARY: 'primary', - ROLE_ANYCAST: 'warning', - ROLE_VIP: 'success', - ROLE_VRRP: 'success', - ROLE_HSRP: 'success', - ROLE_GLBP: 'success', - ROLE_CARP: 'success', - } - # # FHRP @@ -171,17 +139,11 @@ class VLANStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } - # # Services diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index aeb71e70f..0b03cbe79 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -403,7 +403,7 @@ class Prefix(PrimaryModel): prefix_length = property(fset=_set_prefix_length) def get_status_class(self): - return PrefixStatusChoices.CSS_CLASSES.get(self.status) + return PrefixStatusChoices.colors.get(self.status, 'secondary') def get_parents(self, include_self=False): """ @@ -692,7 +692,7 @@ class IPRange(PrimaryModel): prefix_length = property(fset=_set_prefix_length) def get_status_class(self): - return IPRangeStatusChoices.CSS_CLASSES.get(self.status) + return IPRangeStatusChoices.colors.get(self.status, 'secondary') def get_child_ips(self): """ @@ -909,7 +909,7 @@ class IPAddress(PrimaryModel): mask_length = property(fset=_set_mask_length) def get_status_class(self): - return IPAddressStatusChoices.CSS_CLASSES.get(self.status) + return IPAddressStatusChoices.colors.get(self.status, 'secondary') def get_role_class(self): - return IPAddressRoleChoices.CSS_CLASSES.get(self.role) + return IPAddressRoleChoices.colors.get(self.role) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 1c1691a62..3a1725770 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -173,7 +173,7 @@ class VLAN(PrimaryModel): }) def get_status_class(self): - return VLANStatusChoices.CSS_CLASSES.get(self.status) + return VLANStatusChoices.colors.get(self.status, 'secondary') def get_interfaces(self): # Return all device interfaces assigned to this VLAN diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 46d74490a..18fd2f5a6 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -8,20 +8,37 @@ class ChoiceSetMeta(type): def __new__(mcs, name, bases, attrs): # Extend static choices with any configured choices - if 'key' in attrs: + key = attrs.get('key') + if key: try: - attrs['CHOICES'].extend(settings.FIELD_CHOICES[attrs['key']]) + attrs['CHOICES'].extend(settings.FIELD_CHOICES[key]) except KeyError: pass + # Define choice tuples + # TODO: Support optgroup nesting + attrs['_choices'] = [ + (c[0], c[1]) for c in attrs['CHOICES'] + ] + + # Define color maps + # TODO: Support optgroup nesting + colors = {} + for c in attrs['CHOICES']: + try: + colors[c[0]] = c[2] + except IndexError: + pass + attrs['colors'] = colors + return super().__new__(mcs, name, bases, attrs) def __call__(cls, *args, **kwargs): # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable - return getattr(cls, 'CHOICES', ()) + return getattr(cls, '_choices', ()) def __iter__(cls): - choices = getattr(cls, 'CHOICES', ()) + choices = getattr(cls, '_choices', ()) return iter(choices) diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index c121d052e..1aaaf6bf9 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -16,19 +16,10 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = [ - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGED, 'Staged', 'primary'), + (STATUS_FAILED, 'Failed', 'danger'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), ] - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_DECOMMISSIONING: 'warning', - } diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 5a1bcd42f..b19715127 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -329,7 +329,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): }) def get_status_class(self): - return VirtualMachineStatusChoices.CSS_CLASSES.get(self.status) + return VirtualMachineStatusChoices.colors.get(self.status, 'secondary') @property def primary_ip(self): diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 151828c88..2fcfc97aa 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -182,7 +182,7 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslink', args=[self.pk]) def get_status_class(self): - return LinkStatusChoices.CSS_CLASSES.get(self.status) + return LinkStatusChoices.colors.get(self.status) def clean(self): From 124302908a1889549a896e7ed818af85f9bde79d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:19:16 -0500 Subject: [PATCH 009/271] Support nested choice groups --- netbox/utilities/choices.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 18fd2f5a6..ade1bd6fb 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -15,21 +15,21 @@ class ChoiceSetMeta(type): except KeyError: pass - # Define choice tuples - # TODO: Support optgroup nesting - attrs['_choices'] = [ - (c[0], c[1]) for c in attrs['CHOICES'] - ] - - # Define color maps - # TODO: Support optgroup nesting - colors = {} - for c in attrs['CHOICES']: - try: - colors[c[0]] = c[2] - except IndexError: - pass - attrs['colors'] = colors + # Define choice tuples and color maps + attrs['_choices'] = [] + attrs['colors'] = {} + for choice in attrs['CHOICES']: + if isinstance(choice[1], (list, tuple)): + grouped_choices = [] + for c in choice[1]: + grouped_choices.append((c[0], c[1])) + if len(c) == 3: + attrs['colors'][c[0]] = c[2] + attrs['_choices'].append((choice[0], grouped_choices)) + else: + attrs['_choices'].append((choice[0], choice[1])) + if len(choice) == 3: + attrs['colors'][choice[0]] = choice[2] return super().__new__(mcs, name, bases, attrs) @@ -48,12 +48,12 @@ class ChoiceSet(metaclass=ChoiceSetMeta): @classmethod def values(cls): - return [c[0] for c in unpack_grouped_choices(cls.CHOICES)] + return [c[0] for c in unpack_grouped_choices(cls._choices)] @classmethod def as_dict(cls): # Unpack grouped choices before casting as a dict - return dict(unpack_grouped_choices(cls.CHOICES)) + return dict(unpack_grouped_choices(cls._choices)) def unpack_grouped_choices(choices): From 1902ecb8ca92c6a80b3113ba01770dd2078c6400 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:22:05 -0500 Subject: [PATCH 010/271] Drop as_dict() method from ChoiceSet --- netbox/extras/reports.py | 4 ++-- netbox/extras/templatetags/log_levels.py | 2 +- netbox/utilities/choices.py | 5 ----- netbox/utilities/tests/test_choices.py | 5 ----- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index cc623b37c..f53c0ecd0 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -167,8 +167,8 @@ class Report(object): """ Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. """ - if level not in LogLevelChoices.as_dict(): - raise Exception("Unknown logging level: {}".format(level)) + if level not in LogLevelChoices.values(): + raise Exception(f"Unknown logging level: {level}") self._results[self.active_test]['log'].append(( timezone.now().isoformat(), level, diff --git a/netbox/extras/templatetags/log_levels.py b/netbox/extras/templatetags/log_levels.py index 0779a87eb..fba73a74f 100644 --- a/netbox/extras/templatetags/log_levels.py +++ b/netbox/extras/templatetags/log_levels.py @@ -12,6 +12,6 @@ def log_level(level): Display a label indicating a syslog severity (e.g. info, warning, etc.). """ return { - 'name': LogLevelChoices.as_dict()[level], + 'name': dict(LogLevelChoices)[level], 'class': LogLevelChoices.colors.get(level) } diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index ade1bd6fb..f5756ffc4 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -50,11 +50,6 @@ class ChoiceSet(metaclass=ChoiceSetMeta): def values(cls): return [c[0] for c in unpack_grouped_choices(cls._choices)] - @classmethod - def as_dict(cls): - # Unpack grouped choices before casting as a dict - return dict(unpack_grouped_choices(cls._choices)) - def unpack_grouped_choices(choices): """ diff --git a/netbox/utilities/tests/test_choices.py b/netbox/utilities/tests/test_choices.py index bbf75e40e..8dbf5d602 100644 --- a/netbox/utilities/tests/test_choices.py +++ b/netbox/utilities/tests/test_choices.py @@ -30,8 +30,3 @@ class ChoiceSetTestCase(TestCase): def test_values(self): self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3]) - - def test_as_dict(self): - self.assertEqual(ExampleChoices.as_dict(), { - 'a': 'A', 'b': 'B', 'c': 'C', 1: 'One', 2: 'Two', 3: 'Three' - }) From d8be8e25a53d1484b6ffb601bfae8c4ac0b77192 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:31:32 -0500 Subject: [PATCH 011/271] ChoiceSet cleanup --- netbox/utilities/choices.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index f5756ffc4..712783448 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -34,16 +34,18 @@ class ChoiceSetMeta(type): return super().__new__(mcs, name, bases, attrs) def __call__(cls, *args, **kwargs): - # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable + # django-filters will check if a 'choices' value is callable, and if so assume that it returns an iterable return getattr(cls, '_choices', ()) def __iter__(cls): - choices = getattr(cls, '_choices', ()) - return iter(choices) + return iter(getattr(cls, '_choices', ())) class ChoiceSet(metaclass=ChoiceSetMeta): - + """ + Holds an interable of choice tuples suitable for passing to a Django model or form field. Choices can be defined + statically within the class as CHOICES and/or gleaned from the FIELD_CHOICES configuration parameter. + """ CHOICES = list() @classmethod From ea6cdc96737669bf87aef606bbca396d57712465 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 11:28:57 -0500 Subject: [PATCH 012/271] Closes #7650: Add support for local account password validation --- docs/configuration/optional-settings.md | 17 +++++++++++++++++ docs/release-notes/version-3.2.md | 4 ++++ netbox/netbox/configuration.example.py | 11 +++++++++++ netbox/netbox/settings.py | 1 + 4 files changed, 33 insertions(+) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 97c1b6201..fcebf7af6 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -13,6 +13,23 @@ ADMINS = [ --- +## AUTH_PASSWORD_VALIDATORS + +This parameter acts as a pass-through for configuring Django's built-in password validators for local user accounts. If configured, these will be applied whenever a user's password is updated to ensure that it meets minimum criteria such as length or complexity. An example is provided below. For more detail on the available options, please see [the Django documentation](https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation). + +```python +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 10, + } + }, +] +``` + +--- + ## BASE_PATH Default: None diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index a92968bc9..2b48aa711 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,10 @@ * The `asn` query filter for sites now matches against the AS number of assigned ASNs. * The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please use the new contact model introduced in NetBox v3.1 to store contact information for sites. +### Enhancements + +* [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation + ### Other Changes * [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 8130acb2e..c82749e3f 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -72,6 +72,17 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] +# Enable any desired validators for local account passwords below. For a list of included validators, please see the +# Django documentation at https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation. +AUTH_PASSWORD_VALIDATORS = [ + # { + # 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + # 'OPTIONS': { + # 'min_length': 10, + # } + # }, +] + # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9d956250b..5808602a2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -73,6 +73,7 @@ SECRET_KEY = getattr(configuration, 'SECRET_KEY') # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) +AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only From 1dd3d2ec48a3b795d5d77e33937a7cd3ab8619ad Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 11:32:31 -0500 Subject: [PATCH 013/271] Changelog for #8054 --- docs/release-notes/version-3.2.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 2b48aa711..cce60b581 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,22 @@ * The `asn` query filter for sites now matches against the AS number of assigned ASNs. * The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please use the new contact model introduced in NetBox v3.1 to store contact information for sites. +### New Features + +#### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) + +Custom choices can be now added to most status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo'), + ('bar', 'Bar'), + ('baz', 'Baz'), + ) +} +``` + ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation From e91a76c936dc88ade43e541af0cf6e5e09181d05 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 16:28:23 -0500 Subject: [PATCH 014/271] Refactor bulk generic views --- netbox/netbox/views/generic.py | 362 +++++++++++++++++---------------- 1 file changed, 191 insertions(+), 171 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index c6f6305cb..3096b86fc 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -539,6 +539,31 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + def _create_objects(self, form, request): + new_objects = [] + + # Create objects from the expanded. Abort the transaction on the first validation error. + for value in form.cleaned_data['pattern']: + + # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable + # copy of the POST QueryDict so that we can update the target field value. + model_form = self.model_form(request.POST.copy()) + model_form.data[self.pattern_target] = value + + # Validate each new object independently. + if model_form.is_valid(): + obj = model_form.save() + new_objects.append(obj) + else: + # Copy any errors on the pattern target field to the pattern form. + errors = model_form.errors.as_data() + if errors.get(self.pattern_target): + form.add_error('pattern', errors[self.pattern_target]) + # Raise an IntegrityError to break the for loop and abort the transaction. + raise IntegrityError() + + return new_objects + def get(self, request): # Set initial values for visible form fields from query args initial = {} @@ -564,45 +589,23 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if form.is_valid(): logger.debug("Form validation was successful") - pattern = form.cleaned_data['pattern'] - new_objs = [] try: with transaction.atomic(): - - # Create objects from the expanded. Abort the transaction on the first validation error. - for value in pattern: - - # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable - # copy of the POST QueryDict so that we can update the target field value. - model_form = self.model_form(request.POST.copy()) - model_form.data[self.pattern_target] = value - - # Validate each new object independently. - if model_form.is_valid(): - obj = model_form.save() - logger.debug(f"Created {obj} (PK: {obj.pk})") - new_objs.append(obj) - else: - # Copy any errors on the pattern target field to the pattern form. - errors = model_form.errors.as_data() - if errors.get(self.pattern_target): - form.add_error('pattern', errors[self.pattern_target]) - # Raise an IntegrityError to break the for loop and abort the transaction. - raise IntegrityError() + new_objs = self._create_objects(form, request) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): raise PermissionsViolation - # If we make it to this point, validation has succeeded on all new objects. - msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) - logger.info(msg) - messages.success(request, msg) + # If we make it to this point, validation has succeeded on all new objects. + msg = f"Added {len(new_objs)} {model._meta.verbose_name_plural}" + logger.info(msg) + messages.success(request, msg) - if '_addanother' in request.POST: - return redirect(request.path) - return redirect(self.get_return_url(request)) + if '_addanother' in request.POST: + return redirect(request.path) + return redirect(self.get_return_url(request)) except IntegrityError: pass @@ -640,6 +643,45 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + def _create_object(self, model_form): + + # Save the primary object + obj = model_form.save() + + # Enforce object-level permissions + if not self.queryset.filter(pk=obj.pk).first(): + raise PermissionsViolation() + + # Iterate through the related object forms (if any), validating and saving each instance. + for field_name, related_object_form in self.related_object_forms.items(): + + related_obj_pks = [] + for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): + + f = related_object_form(obj, rel_obj_data) + + for subfield_name, field in f.fields.items(): + if subfield_name not in rel_obj_data and hasattr(field, 'initial'): + f.data[subfield_name] = field.initial + + if f.is_valid(): + related_obj = f.save() + related_obj_pks.append(related_obj.pk) + else: + # Replicate errors on the related object form to the primary form for display + for subfield_name, errors in f.errors.items(): + for err in errors: + err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) + model_form.add_error(None, err_msg) + raise AbortTransaction() + + # Enforce object-level permissions on related objects + model = related_object_form.Meta.model + if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): + raise ObjectDoesNotExist + + return obj + def get(self, request): form = ImportForm() @@ -673,44 +715,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): try: with transaction.atomic(): - - # Save the primary object - obj = model_form.save() - - # Enforce object-level permissions - if not self.queryset.filter(pk=obj.pk).first(): - raise PermissionsViolation() - - logger.debug(f"Created {obj} (PK: {obj.pk})") - - # Iterate through the related object forms (if any), validating and saving each instance. - for field_name, related_object_form in self.related_object_forms.items(): - logger.debug("Processing form for related objects: {related_object_form}") - - related_obj_pks = [] - for i, rel_obj_data in enumerate(data.get(field_name, list())): - - f = related_object_form(obj, rel_obj_data) - - for subfield_name, field in f.fields.items(): - if subfield_name not in rel_obj_data and hasattr(field, 'initial'): - f.data[subfield_name] = field.initial - - if f.is_valid(): - related_obj = f.save() - related_obj_pks.append(related_obj.pk) - else: - # Replicate errors on the related object form to the primary form for display - for subfield_name, errors in f.errors.items(): - for err in errors: - err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) - model_form.add_error(None, err_msg) - raise AbortTransaction() - - # Enforce object-level permissions on related objects - model = related_object_form.Meta.model - if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): - raise ObjectDoesNotExist + obj = self._create_object(model_form) except AbortTransaction: clear_webhooks.send(sender=self) @@ -723,9 +728,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if not model_form.errors: logger.info(f"Import object {obj} (PK: {obj.pk})") - messages.success(request, mark_safe('Imported object: {}'.format( - obj.get_absolute_url(), obj - ))) + msg = f'Imported object: {obj}' + messages.success(request, mark_safe(msg)) if '_addanother' in request.POST: return redirect(request.get_full_path()) @@ -733,8 +737,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return_url = form.cleaned_data.get('return_url') if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): return redirect(return_url) - else: - return redirect(self.get_return_url(request, obj)) + return redirect(self.get_return_url(request, obj)) else: logger.debug("Model form validation failed") @@ -799,6 +802,27 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return ImportForm(*args, **kwargs) + def _create_objects(self, form, request): + new_objs = [] + if request.FILES: + headers, records = form.cleaned_data['csv_file'] + else: + headers, records = form.cleaned_data['csv'] + + for row, data in enumerate(records, start=1): + obj_form = self.model_form(data, headers=headers) + restrict_form_fields(obj_form, request.user) + + if obj_form.is_valid(): + obj = self._save_obj(obj_form, request) + new_objs.append(obj) + else: + for field, err in obj_form.errors.items(): + form.add_error('csv', f'Row {row} {field}: {err[0]}') + raise ValidationError("") + + return new_objs + def _save_obj(self, obj_form, request): """ Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). @@ -819,7 +843,6 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def post(self, request): logger = logging.getLogger('netbox.views.BulkImportView') - new_objs = [] form = self._import_form(request.POST, request.FILES) if form.is_valid(): @@ -828,21 +851,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): - if request.FILES: - headers, records = form.cleaned_data['csv_file'] - else: - headers, records = form.cleaned_data['csv'] - for row, data in enumerate(records, start=1): - obj_form = self.model_form(data, headers=headers) - restrict_form_fields(obj_form, request.user) - - if obj_form.is_valid(): - obj = self._save_obj(obj_form, request) - new_objs.append(obj) - else: - for field, err in obj_form.errors.items(): - form.add_error('csv', "Row {} {}: {}".format(row, field, err[0])) - raise ValidationError("") + new_objs = self._create_objects(form, request) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): @@ -886,7 +895,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): Edit objects in bulk. queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filter: FilterSet to apply when deleting by QuerySet + filterset: FilterSet to apply when deleting by QuerySet table: The table used to display devices being edited form: The form class used to edit objects in bulk template_name: The name of the template @@ -900,6 +909,63 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') + def _update_objects(self, form, request): + custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] + standard_fields = [ + field for field in form.fields if field not in custom_fields + ['pk'] + ] + nullified_fields = request.POST.getlist('_nullify') + updated_objects = [] + + for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + # Update standard fields. If a field is listed in _nullify, delete its value. + for name in standard_fields: + + try: + model_field = self.queryset.model._meta.get_field(name) + except FieldDoesNotExist: + # This form field is used to modify a field rather than set its value directly + model_field = None + + # Handle nullification + if name in form.nullable_fields and name in nullified_fields: + if isinstance(model_field, ManyToManyField): + getattr(obj, name).set([]) + else: + setattr(obj, name, None if model_field.null else '') + + # ManyToManyFields + elif isinstance(model_field, ManyToManyField): + if form.cleaned_data[name]: + getattr(obj, name).set(form.cleaned_data[name]) + # Normal fields + elif name in form.changed_data: + setattr(obj, name, form.cleaned_data[name]) + + # Update custom fields + for name in custom_fields: + if name in form.nullable_fields and name in nullified_fields: + obj.custom_field_data[name] = None + elif name in form.changed_data: + obj.custom_field_data[name] = form.cleaned_data[name] + + obj.full_clean() + obj.save() + updated_objects.append(obj) + + # Add/remove tags + if form.cleaned_data.get('add_tags', None): + obj.tags.add(*form.cleaned_data['add_tags']) + if form.cleaned_data.get('remove_tags', None): + obj.tags.remove(*form.cleaned_data['remove_tags']) + + return updated_objects + def get(self, request): return redirect(self.get_return_url(request)) @@ -932,78 +998,26 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if form.is_valid(): logger.debug("Form validation was successful") - custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] - standard_fields = [ - field for field in form.fields if field not in custom_fields + ['pk'] - ] - nullified_fields = request.POST.getlist('_nullify') try: with transaction.atomic(): - - updated_objects = [] - for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - # Update standard fields. If a field is listed in _nullify, delete its value. - for name in standard_fields: - - try: - model_field = model._meta.get_field(name) - except FieldDoesNotExist: - # This form field is used to modify a field rather than set its value directly - model_field = None - - # Handle nullification - if name in form.nullable_fields and name in nullified_fields: - if isinstance(model_field, ManyToManyField): - getattr(obj, name).set([]) - else: - setattr(obj, name, None if model_field.null else '') - - # ManyToManyFields - elif isinstance(model_field, ManyToManyField): - if form.cleaned_data[name]: - getattr(obj, name).set(form.cleaned_data[name]) - # Normal fields - elif name in form.changed_data: - setattr(obj, name, form.cleaned_data[name]) - - # Update custom fields - for name in custom_fields: - if name in form.nullable_fields and name in nullified_fields: - obj.custom_field_data[name] = None - elif name in form.changed_data: - obj.custom_field_data[name] = form.cleaned_data[name] - - obj.full_clean() - obj.save() - updated_objects.append(obj) - logger.debug(f"Saved {obj} (PK: {obj.pk})") - - # Add/remove tags - if form.cleaned_data.get('add_tags', None): - obj.tags.add(*form.cleaned_data['add_tags']) - if form.cleaned_data.get('remove_tags', None): - obj.tags.remove(*form.cleaned_data['remove_tags']) + updated_objects = self._update_objects(form, request) # Enforce object-level permissions - if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects): + object_count = self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() + if object_count != len(updated_objects): raise PermissionsViolation if updated_objects: - msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural) + msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}' logger.info(msg) messages.success(self.request, msg) return redirect(self.get_return_url(request)) except ValidationError as e: - messages.error(self.request, "{} failed validation: {}".format(obj, ", ".join(e.messages))) + messages.error(self.request, ", ".join(e.messages)) clear_webhooks.send(sender=self) except PermissionsViolation: @@ -1016,7 +1030,6 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): logger.debug("Form validation failed") else: - form = self.form(model, initial=initial_data) restrict_form_fields(form, request.user) @@ -1037,6 +1050,9 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for renaming objects in bulk. + + queryset: QuerySet of objects being renamed + template_name: The name of the template """ queryset = None template_name = 'generic/object_bulk_rename.html' @@ -1056,6 +1072,29 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') + def _rename_objects(self, form, selected_objects): + renamed_pks = [] + + for obj in selected_objects: + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + find = form.cleaned_data['find'] + replace = form.cleaned_data['replace'] + if form.cleaned_data['use_regex']: + try: + obj.new_name = re.sub(find, replace, obj.name) + # Catch regex group reference errors + except re.error: + obj.new_name = obj.name + else: + obj.new_name = obj.name.replace(find, replace) + renamed_pks.append(obj.pk) + + return renamed_pks + def post(self, request): logger = logging.getLogger('netbox.views.BulkRenameView') @@ -1066,24 +1105,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if form.is_valid(): try: with transaction.atomic(): - renamed_pks = [] - for obj in selected_objects: - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - find = form.cleaned_data['find'] - replace = form.cleaned_data['replace'] - if form.cleaned_data['use_regex']: - try: - obj.new_name = re.sub(find, replace, obj.name) - # Catch regex group reference errors - except re.error: - obj.new_name = obj.name - else: - obj.new_name = obj.name.replace(find, replace) - renamed_pks.append(obj.pk) + renamed_pks = self._rename_objects(form, selected_objects) if '_apply' in request.POST: for obj in selected_objects: @@ -1094,10 +1116,8 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects): raise PermissionsViolation - messages.success(request, "Renamed {} {}".format( - len(selected_objects), - self.queryset.model._meta.verbose_name_plural - )) + model_name = self.queryset.model._meta.verbose_name_plural + messages.success(request, f"Renamed {len(selected_objects)} {model_name}") return redirect(self.get_return_url(request)) except PermissionsViolation: @@ -1123,7 +1143,7 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): Delete objects in bulk. queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filter: FilterSet to apply when deleting by QuerySet + filterset: FilterSet to apply when deleting by QuerySet table: The table used to display devices being deleted form: The form class used to delete objects in bulk template_name: The name of the template From 5f9f0e3ed3379582db43718f0252931ed23f622e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 16:39:45 -0500 Subject: [PATCH 015/271] Split generic views into separate modules --- netbox/netbox/views/generic/__init__.py | 2 + netbox/netbox/views/generic/bulk_views.py | 721 +++++++++++++ .../{generic.py => generic/object_views.py} | 978 +++--------------- 3 files changed, 871 insertions(+), 830 deletions(-) create mode 100644 netbox/netbox/views/generic/__init__.py create mode 100644 netbox/netbox/views/generic/bulk_views.py rename netbox/netbox/views/{generic.py => generic/object_views.py} (50%) diff --git a/netbox/netbox/views/generic/__init__.py b/netbox/netbox/views/generic/__init__.py new file mode 100644 index 000000000..9dd933568 --- /dev/null +++ b/netbox/netbox/views/generic/__init__.py @@ -0,0 +1,2 @@ +from .object_views import * +from .bulk_views import * diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py new file mode 100644 index 000000000..bef2c015d --- /dev/null +++ b/netbox/netbox/views/generic/bulk_views.py @@ -0,0 +1,721 @@ +import logging +import re +from copy import deepcopy + +from django.contrib import messages +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db import transaction, IntegrityError +from django.db.models import ManyToManyField, ProtectedError +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea +from django.shortcuts import redirect, render +from django.views.generic import View + +from extras.signals import clear_webhooks +from utilities.error_handlers import handle_protectederror +from utilities.exceptions import PermissionsViolation +from utilities.forms import ( + BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, +) +from utilities.permissions import get_permission_for_model +from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin + +__all__ = ( + 'BulkComponentCreateView', + 'BulkCreateView', + 'BulkDeleteView', + 'BulkEditView', + 'BulkImportView', + 'BulkRenameView', +) + + +class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + Create new objects in bulk. + + queryset: Base queryset for the objects being created + form: Form class which provides the `pattern` field + model_form: The ModelForm used to create individual objects + pattern_target: Name of the field to be evaluated as a pattern (if any) + template_name: The name of the template + """ + queryset = None + form = None + model_form = None + pattern_target = '' + template_name = None + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + + def _create_objects(self, form, request): + new_objects = [] + + # Create objects from the expanded. Abort the transaction on the first validation error. + for value in form.cleaned_data['pattern']: + + # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable + # copy of the POST QueryDict so that we can update the target field value. + model_form = self.model_form(request.POST.copy()) + model_form.data[self.pattern_target] = value + + # Validate each new object independently. + if model_form.is_valid(): + obj = model_form.save() + new_objects.append(obj) + else: + # Copy any errors on the pattern target field to the pattern form. + errors = model_form.errors.as_data() + if errors.get(self.pattern_target): + form.add_error('pattern', errors[self.pattern_target]) + # Raise an IntegrityError to break the for loop and abort the transaction. + raise IntegrityError() + + return new_objects + + def get(self, request): + # Set initial values for visible form fields from query args + initial = {} + for field in getattr(self.model_form._meta, 'fields', []): + if request.GET.get(field): + initial[field] = request.GET[field] + + form = self.form() + model_form = self.model_form(initial=initial) + + return render(request, self.template_name, { + 'obj_type': self.model_form._meta.model._meta.verbose_name, + 'form': form, + 'model_form': model_form, + 'return_url': self.get_return_url(request), + }) + + def post(self, request): + logger = logging.getLogger('netbox.views.BulkCreateView') + model = self.queryset.model + form = self.form(request.POST) + model_form = self.model_form(request.POST) + + if form.is_valid(): + logger.debug("Form validation was successful") + + try: + with transaction.atomic(): + new_objs = self._create_objects(form, request) + + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise PermissionsViolation + + # If we make it to this point, validation has succeeded on all new objects. + msg = f"Added {len(new_objs)} {model._meta.verbose_name_plural}" + logger.info(msg) + messages.success(request, msg) + + if '_addanother' in request.POST: + return redirect(request.path) + return redirect(self.get_return_url(request)) + + except IntegrityError: + pass + + except PermissionsViolation: + msg = "Object creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + + else: + logger.debug("Form validation failed") + + return render(request, self.template_name, { + 'form': form, + 'model_form': model_form, + 'obj_type': model._meta.verbose_name, + 'return_url': self.get_return_url(request), + }) + + +class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + Import objects in bulk (CSV format). + + queryset: Base queryset for the model + model_form: The form used to create each imported object + table: The django-tables2 Table used to render the list of imported objects + template_name: The name of the template + widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) + """ + queryset = None + model_form = None + table = None + template_name = 'generic/object_bulk_import.html' + widget_attrs = {} + + def _import_form(self, *args, **kwargs): + + class ImportForm(BootstrapMixin, Form): + csv = CSVDataField( + from_form=self.model_form, + widget=Textarea(attrs=self.widget_attrs) + ) + csv_file = CSVFileField( + label="CSV file", + from_form=self.model_form, + required=False + ) + + def clean(self): + csv_rows = self.cleaned_data['csv'][1] if 'csv' in self.cleaned_data else None + csv_file = self.files.get('csv_file') + + # Check that the user has not submitted both text data and a file + if csv_rows and csv_file: + raise ValidationError( + "Cannot process CSV text and file attachment simultaneously. Please choose only one import " + "method." + ) + + return ImportForm(*args, **kwargs) + + def _create_objects(self, form, request): + new_objs = [] + if request.FILES: + headers, records = form.cleaned_data['csv_file'] + else: + headers, records = form.cleaned_data['csv'] + + for row, data in enumerate(records, start=1): + obj_form = self.model_form(data, headers=headers) + restrict_form_fields(obj_form, request.user) + + if obj_form.is_valid(): + obj = self._save_obj(obj_form, request) + new_objs.append(obj) + else: + for field, err in obj_form.errors.items(): + form.add_error('csv', f'Row {row} {field}: {err[0]}') + raise ValidationError("") + + return new_objs + + def _save_obj(self, obj_form, request): + """ + Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). + """ + return obj_form.save() + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + + def get(self, request): + + return render(request, self.template_name, { + 'form': self._import_form(), + 'fields': self.model_form().fields, + 'obj_type': self.model_form._meta.model._meta.verbose_name, + 'return_url': self.get_return_url(request), + }) + + def post(self, request): + logger = logging.getLogger('netbox.views.BulkImportView') + form = self._import_form(request.POST, request.FILES) + + if form.is_valid(): + logger.debug("Form validation was successful") + + try: + # Iterate through CSV data and bind each row to a new model form instance. + with transaction.atomic(): + new_objs = self._create_objects(form, request) + + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise PermissionsViolation + + # Compile a table containing the imported objects + obj_table = self.table(new_objs) + + if new_objs: + msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural) + logger.info(msg) + messages.success(request, msg) + + return render(request, "import_success.html", { + 'table': obj_table, + 'return_url': self.get_return_url(request), + }) + + except ValidationError: + clear_webhooks.send(sender=self) + + except PermissionsViolation: + msg = "Object import failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + clear_webhooks.send(sender=self) + + else: + logger.debug("Form validation failed") + + return render(request, self.template_name, { + 'form': form, + 'fields': self.model_form().fields, + 'obj_type': self.model_form._meta.model._meta.verbose_name, + 'return_url': self.get_return_url(request), + }) + + +class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + Edit objects in bulk. + + queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + filterset: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being edited + form: The form class used to edit objects in bulk + template_name: The name of the template + """ + queryset = None + filterset = None + table = None + form = None + template_name = 'generic/object_bulk_edit.html' + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') + + def _update_objects(self, form, request): + custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] + standard_fields = [ + field for field in form.fields if field not in custom_fields + ['pk'] + ] + nullified_fields = request.POST.getlist('_nullify') + updated_objects = [] + + for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + # Update standard fields. If a field is listed in _nullify, delete its value. + for name in standard_fields: + + try: + model_field = self.queryset.model._meta.get_field(name) + except FieldDoesNotExist: + # This form field is used to modify a field rather than set its value directly + model_field = None + + # Handle nullification + if name in form.nullable_fields and name in nullified_fields: + if isinstance(model_field, ManyToManyField): + getattr(obj, name).set([]) + else: + setattr(obj, name, None if model_field.null else '') + + # ManyToManyFields + elif isinstance(model_field, ManyToManyField): + if form.cleaned_data[name]: + getattr(obj, name).set(form.cleaned_data[name]) + # Normal fields + elif name in form.changed_data: + setattr(obj, name, form.cleaned_data[name]) + + # Update custom fields + for name in custom_fields: + if name in form.nullable_fields and name in nullified_fields: + obj.custom_field_data[name] = None + elif name in form.changed_data: + obj.custom_field_data[name] = form.cleaned_data[name] + + obj.full_clean() + obj.save() + updated_objects.append(obj) + + # Add/remove tags + if form.cleaned_data.get('add_tags', None): + obj.tags.add(*form.cleaned_data['add_tags']) + if form.cleaned_data.get('remove_tags', None): + obj.tags.remove(*form.cleaned_data['remove_tags']) + + return updated_objects + + def get(self, request): + return redirect(self.get_return_url(request)) + + def post(self, request, **kwargs): + logger = logging.getLogger('netbox.views.BulkEditView') + model = self.queryset.model + + # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. + if request.POST.get('_all') and self.filterset is not None: + pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True)).qs + else: + pk_list = request.POST.getlist('pk') + + # Include the PK list as initial data for the form + initial_data = {'pk': pk_list} + + # Check for other contextual data needed for the form. We avoid passing all of request.GET because the + # filter values will conflict with the bulk edit form fields. + # TODO: Find a better way to accomplish this + if 'device' in request.GET: + initial_data['device'] = request.GET.get('device') + elif 'device_type' in request.GET: + initial_data['device_type'] = request.GET.get('device_type') + elif 'virtual_machine' in request.GET: + initial_data['virtual_machine'] = request.GET.get('virtual_machine') + + if '_apply' in request.POST: + form = self.form(model, request.POST, initial=initial_data) + restrict_form_fields(form, request.user) + + if form.is_valid(): + logger.debug("Form validation was successful") + + try: + + with transaction.atomic(): + updated_objects = self._update_objects(form, request) + + # Enforce object-level permissions + object_count = self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() + if object_count != len(updated_objects): + raise PermissionsViolation + + if updated_objects: + msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}' + logger.info(msg) + messages.success(self.request, msg) + + return redirect(self.get_return_url(request)) + + except ValidationError as e: + messages.error(self.request, ", ".join(e.messages)) + clear_webhooks.send(sender=self) + + except PermissionsViolation: + msg = "Object update failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + clear_webhooks.send(sender=self) + + else: + logger.debug("Form validation failed") + + else: + form = self.form(model, initial=initial_data) + restrict_form_fields(form, request.user) + + # Retrieve objects being edited + table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) + if not table.rows: + messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural)) + return redirect(self.get_return_url(request)) + + return render(request, self.template_name, { + 'form': form, + 'table': table, + 'obj_type_plural': model._meta.verbose_name_plural, + 'return_url': self.get_return_url(request), + }) + + +class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + An extendable view for renaming objects in bulk. + + queryset: QuerySet of objects being renamed + template_name: The name of the template + """ + queryset = None + template_name = 'generic/object_bulk_rename.html' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create a new Form class from BulkRenameForm + class _Form(BulkRenameForm): + pk = ModelMultipleChoiceField( + queryset=self.queryset, + widget=MultipleHiddenInput() + ) + + self.form = _Form + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') + + def _rename_objects(self, form, selected_objects): + renamed_pks = [] + + for obj in selected_objects: + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + find = form.cleaned_data['find'] + replace = form.cleaned_data['replace'] + if form.cleaned_data['use_regex']: + try: + obj.new_name = re.sub(find, replace, obj.name) + # Catch regex group reference errors + except re.error: + obj.new_name = obj.name + else: + obj.new_name = obj.name.replace(find, replace) + renamed_pks.append(obj.pk) + + return renamed_pks + + def post(self, request): + logger = logging.getLogger('netbox.views.BulkRenameView') + + if '_preview' in request.POST or '_apply' in request.POST: + form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) + selected_objects = self.queryset.filter(pk__in=form.initial['pk']) + + if form.is_valid(): + try: + with transaction.atomic(): + renamed_pks = self._rename_objects(form, selected_objects) + + if '_apply' in request.POST: + for obj in selected_objects: + obj.name = obj.new_name + obj.save() + + # Enforce constrained permissions + if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects): + raise PermissionsViolation + + model_name = self.queryset.model._meta.verbose_name_plural + messages.success(request, f"Renamed {len(selected_objects)} {model_name}") + return redirect(self.get_return_url(request)) + + except PermissionsViolation: + msg = "Object update failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + clear_webhooks.send(sender=self) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + selected_objects = self.queryset.filter(pk__in=form.initial['pk']) + + return render(request, self.template_name, { + 'form': form, + 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, + 'selected_objects': selected_objects, + 'return_url': self.get_return_url(request), + }) + + +class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + Delete objects in bulk. + + queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + filterset: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being deleted + form: The form class used to delete objects in bulk + template_name: The name of the template + """ + queryset = None + filterset = None + table = None + form = None + template_name = 'generic/object_bulk_delete.html' + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'delete') + + def get(self, request): + return redirect(self.get_return_url(request)) + + def post(self, request, **kwargs): + logger = logging.getLogger('netbox.views.BulkDeleteView') + model = self.queryset.model + + # Are we deleting *all* objects in the queryset or just a selected subset? + if request.POST.get('_all'): + qs = model.objects.all() + if self.filterset is not None: + qs = self.filterset(request.GET, qs).qs + pk_list = qs.only('pk').values_list('pk', flat=True) + else: + pk_list = [int(pk) for pk in request.POST.getlist('pk')] + + form_cls = self.get_form() + + if '_confirm' in request.POST: + form = form_cls(request.POST) + if form.is_valid(): + logger.debug("Form validation was successful") + + # Delete objects + queryset = self.queryset.filter(pk__in=pk_list) + deleted_count = queryset.count() + try: + for obj in queryset: + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + obj.delete() + except ProtectedError as e: + logger.info("Caught ProtectedError while attempting to delete objects") + handle_protectederror(queryset, request, e) + return redirect(self.get_return_url(request)) + + msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}" + logger.info(msg) + messages.success(request, msg) + return redirect(self.get_return_url(request)) + + else: + logger.debug("Form validation failed") + + else: + form = form_cls(initial={ + 'pk': pk_list, + 'return_url': self.get_return_url(request), + }) + + # Retrieve objects being deleted + table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) + if not table.rows: + messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural)) + return redirect(self.get_return_url(request)) + + return render(request, self.template_name, { + 'form': form, + 'obj_type_plural': model._meta.verbose_name_plural, + 'table': table, + 'return_url': self.get_return_url(request), + }) + + def get_form(self): + """ + Provide a standard bulk delete form if none has been specified for the view + """ + class BulkDeleteForm(ConfirmationForm): + pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) + + if self.form: + return self.form + + return BulkDeleteForm + + +# +# Device/VirtualMachine components +# + +class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. + """ + parent_model = None + parent_field = None + form = None + queryset = None + model_form = None + filterset = None + table = None + template_name = 'generic/object_bulk_add_component.html' + + def get_required_permission(self): + return f'dcim.add_{self.queryset.model._meta.model_name}' + + def post(self, request): + logger = logging.getLogger('netbox.views.BulkComponentCreateView') + parent_model_name = self.parent_model._meta.verbose_name_plural + model_name = self.queryset.model._meta.verbose_name_plural + + # Are we editing *all* objects in the queryset or just a selected subset? + if request.POST.get('_all') and self.filterset is not None: + pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs] + else: + pk_list = [int(pk) for pk in request.POST.getlist('pk')] + + selected_objects = self.parent_model.objects.filter(pk__in=pk_list) + if not selected_objects: + messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural)) + return redirect(self.get_return_url(request)) + table = self.table(selected_objects) + + if '_create' in request.POST: + form = self.form(request.POST) + + if form.is_valid(): + logger.debug("Form validation was successful") + + new_components = [] + data = deepcopy(form.cleaned_data) + + try: + with transaction.atomic(): + + for obj in data['pk']: + + names = data['name_pattern'] + labels = data['label_pattern'] if 'label_pattern' in data else None + for i, name in enumerate(names): + label = labels[i] if labels else None + + component_data = { + self.parent_field: obj.pk, + 'name': name, + 'label': label + } + component_data.update(data) + component_form = self.model_form(component_data) + if component_form.is_valid(): + instance = component_form.save() + logger.debug(f"Created {instance} on {instance.parent_object}") + new_components.append(instance) + else: + for field, errors in component_form.errors.as_data().items(): + for e in errors: + form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components): + raise PermissionsViolation + + except IntegrityError: + clear_webhooks.send(sender=self) + + except PermissionsViolation: + msg = "Component creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + clear_webhooks.send(sender=self) + + if not form.errors: + msg = "Added {} {} to {} {}.".format( + len(new_components), + model_name, + len(form.cleaned_data['pk']), + parent_model_name + ) + logger.info(msg) + messages.success(request, msg) + + return redirect(self.get_return_url(request)) + + else: + logger.debug("Form validation failed") + + else: + form = self.form(initial={'pk': pk_list}) + + return render(request, self.template_name, { + 'form': form, + 'parent_model_name': parent_model_name, + 'model_name': model_name, + 'table': table, + 'return_url': self.get_return_url(request), + }) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic/object_views.py similarity index 50% rename from netbox/netbox/views/generic.py rename to netbox/netbox/views/generic/object_views.py index 3096b86fc..588b51062 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic/object_views.py @@ -1,13 +1,11 @@ import logging -import re from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError -from django.db import transaction, IntegrityError -from django.db.models import ManyToManyField, ProtectedError -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.db.models import ProtectedError from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.html import escape @@ -20,15 +18,23 @@ from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortTransaction, PermissionsViolation -from utilities.forms import ( - BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields, -) +from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.tables import paginate_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +__all__ = ( + 'ComponentCreateView', + 'ObjectChildrenView', + 'ObjectDeleteView', + 'ObjectEditView', + 'ObjectImportView', + 'ObjectListView', + 'ObjectView', +) + class ObjectView(ObjectPermissionRequiredMixin, View): """ @@ -296,6 +302,140 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): return render(request, self.template_name, context) +class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): + """ + Import a single object (YAML or JSON format). + + queryset: Base queryset for the objects being created + model_form: The ModelForm used to create individual objects + related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects + template_name: The name of the template + """ + queryset = None + model_form = None + related_object_forms = dict() + template_name = 'generic/object_import.html' + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + + def _create_object(self, model_form): + + # Save the primary object + obj = model_form.save() + + # Enforce object-level permissions + if not self.queryset.filter(pk=obj.pk).first(): + raise PermissionsViolation() + + # Iterate through the related object forms (if any), validating and saving each instance. + for field_name, related_object_form in self.related_object_forms.items(): + + related_obj_pks = [] + for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): + + f = related_object_form(obj, rel_obj_data) + + for subfield_name, field in f.fields.items(): + if subfield_name not in rel_obj_data and hasattr(field, 'initial'): + f.data[subfield_name] = field.initial + + if f.is_valid(): + related_obj = f.save() + related_obj_pks.append(related_obj.pk) + else: + # Replicate errors on the related object form to the primary form for display + for subfield_name, errors in f.errors.items(): + for err in errors: + err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) + model_form.add_error(None, err_msg) + raise AbortTransaction() + + # Enforce object-level permissions on related objects + model = related_object_form.Meta.model + if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): + raise ObjectDoesNotExist + + return obj + + def get(self, request): + form = ImportForm() + + return render(request, self.template_name, { + 'form': form, + 'obj_type': self.queryset.model._meta.verbose_name, + 'return_url': self.get_return_url(request), + }) + + def post(self, request): + logger = logging.getLogger('netbox.views.ObjectImportView') + form = ImportForm(request.POST) + + if form.is_valid(): + logger.debug("Import form validation was successful") + + # Initialize model form + data = form.cleaned_data['data'] + model_form = self.model_form(data) + restrict_form_fields(model_form, request.user) + + # Assign default values for any fields which were not specified. We have to do this manually because passing + # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not + # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the + # applicable field defaults as needed prior to form validation. + for field_name, field in model_form.fields.items(): + if field_name not in data and hasattr(field, 'initial'): + model_form.data[field_name] = field.initial + + if model_form.is_valid(): + + try: + with transaction.atomic(): + obj = self._create_object(model_form) + + except AbortTransaction: + clear_webhooks.send(sender=self) + + except PermissionsViolation: + msg = "Object creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + clear_webhooks.send(sender=self) + + if not model_form.errors: + logger.info(f"Import object {obj} (PK: {obj.pk})") + msg = f'Imported object: {obj}' + messages.success(request, mark_safe(msg)) + + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + + return_url = form.cleaned_data.get('return_url') + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): + return redirect(return_url) + return redirect(self.get_return_url(request, obj)) + + else: + logger.debug("Model form validation failed") + + # Replicate model form errors for display + for field, errors in model_form.errors.items(): + for err in errors: + if field == '__all__': + form.add_error(None, err) + else: + form.add_error(None, "{}: {}".format(field, err)) + + else: + logger.debug("Import form validation failed") + + return render(request, self.template_name, { + 'form': form, + 'obj_type': self.queryset.model._meta.verbose_name, + 'return_url': self.get_return_url(request), + }) + + class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create or edit a single object. @@ -520,720 +660,6 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - Create new objects in bulk. - - queryset: Base queryset for the objects being created - form: Form class which provides the `pattern` field - model_form: The ModelForm used to create individual objects - pattern_target: Name of the field to be evaluated as a pattern (if any) - template_name: The name of the template - """ - queryset = None - form = None - model_form = None - pattern_target = '' - template_name = None - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'add') - - def _create_objects(self, form, request): - new_objects = [] - - # Create objects from the expanded. Abort the transaction on the first validation error. - for value in form.cleaned_data['pattern']: - - # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable - # copy of the POST QueryDict so that we can update the target field value. - model_form = self.model_form(request.POST.copy()) - model_form.data[self.pattern_target] = value - - # Validate each new object independently. - if model_form.is_valid(): - obj = model_form.save() - new_objects.append(obj) - else: - # Copy any errors on the pattern target field to the pattern form. - errors = model_form.errors.as_data() - if errors.get(self.pattern_target): - form.add_error('pattern', errors[self.pattern_target]) - # Raise an IntegrityError to break the for loop and abort the transaction. - raise IntegrityError() - - return new_objects - - def get(self, request): - # Set initial values for visible form fields from query args - initial = {} - for field in getattr(self.model_form._meta, 'fields', []): - if request.GET.get(field): - initial[field] = request.GET[field] - - form = self.form() - model_form = self.model_form(initial=initial) - - return render(request, self.template_name, { - 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'form': form, - 'model_form': model_form, - 'return_url': self.get_return_url(request), - }) - - def post(self, request): - logger = logging.getLogger('netbox.views.BulkCreateView') - model = self.queryset.model - form = self.form(request.POST) - model_form = self.model_form(request.POST) - - if form.is_valid(): - logger.debug("Form validation was successful") - - try: - with transaction.atomic(): - new_objs = self._create_objects(form, request) - - # Enforce object-level permissions - if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): - raise PermissionsViolation - - # If we make it to this point, validation has succeeded on all new objects. - msg = f"Added {len(new_objs)} {model._meta.verbose_name_plural}" - logger.info(msg) - messages.success(request, msg) - - if '_addanother' in request.POST: - return redirect(request.path) - return redirect(self.get_return_url(request)) - - except IntegrityError: - pass - - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) - - else: - logger.debug("Form validation failed") - - return render(request, self.template_name, { - 'form': form, - 'model_form': model_form, - 'obj_type': model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - -class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - Import a single object (YAML or JSON format). - - queryset: Base queryset for the objects being created - model_form: The ModelForm used to create individual objects - related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects - template_name: The name of the template - """ - queryset = None - model_form = None - related_object_forms = dict() - template_name = 'generic/object_import.html' - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'add') - - def _create_object(self, model_form): - - # Save the primary object - obj = model_form.save() - - # Enforce object-level permissions - if not self.queryset.filter(pk=obj.pk).first(): - raise PermissionsViolation() - - # Iterate through the related object forms (if any), validating and saving each instance. - for field_name, related_object_form in self.related_object_forms.items(): - - related_obj_pks = [] - for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): - - f = related_object_form(obj, rel_obj_data) - - for subfield_name, field in f.fields.items(): - if subfield_name not in rel_obj_data and hasattr(field, 'initial'): - f.data[subfield_name] = field.initial - - if f.is_valid(): - related_obj = f.save() - related_obj_pks.append(related_obj.pk) - else: - # Replicate errors on the related object form to the primary form for display - for subfield_name, errors in f.errors.items(): - for err in errors: - err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err) - model_form.add_error(None, err_msg) - raise AbortTransaction() - - # Enforce object-level permissions on related objects - model = related_object_form.Meta.model - if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): - raise ObjectDoesNotExist - - return obj - - def get(self, request): - form = ImportForm() - - return render(request, self.template_name, { - 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - def post(self, request): - logger = logging.getLogger('netbox.views.ObjectImportView') - form = ImportForm(request.POST) - - if form.is_valid(): - logger.debug("Import form validation was successful") - - # Initialize model form - data = form.cleaned_data['data'] - model_form = self.model_form(data) - restrict_form_fields(model_form, request.user) - - # Assign default values for any fields which were not specified. We have to do this manually because passing - # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not - # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the - # applicable field defaults as needed prior to form validation. - for field_name, field in model_form.fields.items(): - if field_name not in data and hasattr(field, 'initial'): - model_form.data[field_name] = field.initial - - if model_form.is_valid(): - - try: - with transaction.atomic(): - obj = self._create_object(model_form) - - except AbortTransaction: - clear_webhooks.send(sender=self) - - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) - clear_webhooks.send(sender=self) - - if not model_form.errors: - logger.info(f"Import object {obj} (PK: {obj.pk})") - msg = f'Imported object: {obj}' - messages.success(request, mark_safe(msg)) - - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - - return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): - return redirect(return_url) - return redirect(self.get_return_url(request, obj)) - - else: - logger.debug("Model form validation failed") - - # Replicate model form errors for display - for field, errors in model_form.errors.items(): - for err in errors: - if field == '__all__': - form.add_error(None, err) - else: - form.add_error(None, "{}: {}".format(field, err)) - - else: - logger.debug("Import form validation failed") - - return render(request, self.template_name, { - 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - -class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - Import objects in bulk (CSV format). - - queryset: Base queryset for the model - model_form: The form used to create each imported object - table: The django-tables2 Table used to render the list of imported objects - template_name: The name of the template - widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) - """ - queryset = None - model_form = None - table = None - template_name = 'generic/object_bulk_import.html' - widget_attrs = {} - - def _import_form(self, *args, **kwargs): - - class ImportForm(BootstrapMixin, Form): - csv = CSVDataField( - from_form=self.model_form, - widget=Textarea(attrs=self.widget_attrs) - ) - csv_file = CSVFileField( - label="CSV file", - from_form=self.model_form, - required=False - ) - - def clean(self): - csv_rows = self.cleaned_data['csv'][1] if 'csv' in self.cleaned_data else None - csv_file = self.files.get('csv_file') - - # Check that the user has not submitted both text data and a file - if csv_rows and csv_file: - raise ValidationError( - "Cannot process CSV text and file attachment simultaneously. Please choose only one import " - "method." - ) - - return ImportForm(*args, **kwargs) - - def _create_objects(self, form, request): - new_objs = [] - if request.FILES: - headers, records = form.cleaned_data['csv_file'] - else: - headers, records = form.cleaned_data['csv'] - - for row, data in enumerate(records, start=1): - obj_form = self.model_form(data, headers=headers) - restrict_form_fields(obj_form, request.user) - - if obj_form.is_valid(): - obj = self._save_obj(obj_form, request) - new_objs.append(obj) - else: - for field, err in obj_form.errors.items(): - form.add_error('csv', f'Row {row} {field}: {err[0]}') - raise ValidationError("") - - return new_objs - - def _save_obj(self, obj_form, request): - """ - Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). - """ - return obj_form.save() - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'add') - - def get(self, request): - - return render(request, self.template_name, { - 'form': self._import_form(), - 'fields': self.model_form().fields, - 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - def post(self, request): - logger = logging.getLogger('netbox.views.BulkImportView') - form = self._import_form(request.POST, request.FILES) - - if form.is_valid(): - logger.debug("Form validation was successful") - - try: - # Iterate through CSV data and bind each row to a new model form instance. - with transaction.atomic(): - new_objs = self._create_objects(form, request) - - # Enforce object-level permissions - if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): - raise PermissionsViolation - - # Compile a table containing the imported objects - obj_table = self.table(new_objs) - - if new_objs: - msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural) - logger.info(msg) - messages.success(request, msg) - - return render(request, "import_success.html", { - 'table': obj_table, - 'return_url': self.get_return_url(request), - }) - - except ValidationError: - clear_webhooks.send(sender=self) - - except PermissionsViolation: - msg = "Object import failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) - clear_webhooks.send(sender=self) - - else: - logger.debug("Form validation failed") - - return render(request, self.template_name, { - 'form': form, - 'fields': self.model_form().fields, - 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.get_return_url(request), - }) - - -class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - Edit objects in bulk. - - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being edited - form: The form class used to edit objects in bulk - template_name: The name of the template - """ - queryset = None - filterset = None - table = None - form = None - template_name = 'generic/object_bulk_edit.html' - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'change') - - def _update_objects(self, form, request): - custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] - standard_fields = [ - field for field in form.fields if field not in custom_fields + ['pk'] - ] - nullified_fields = request.POST.getlist('_nullify') - updated_objects = [] - - for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - # Update standard fields. If a field is listed in _nullify, delete its value. - for name in standard_fields: - - try: - model_field = self.queryset.model._meta.get_field(name) - except FieldDoesNotExist: - # This form field is used to modify a field rather than set its value directly - model_field = None - - # Handle nullification - if name in form.nullable_fields and name in nullified_fields: - if isinstance(model_field, ManyToManyField): - getattr(obj, name).set([]) - else: - setattr(obj, name, None if model_field.null else '') - - # ManyToManyFields - elif isinstance(model_field, ManyToManyField): - if form.cleaned_data[name]: - getattr(obj, name).set(form.cleaned_data[name]) - # Normal fields - elif name in form.changed_data: - setattr(obj, name, form.cleaned_data[name]) - - # Update custom fields - for name in custom_fields: - if name in form.nullable_fields and name in nullified_fields: - obj.custom_field_data[name] = None - elif name in form.changed_data: - obj.custom_field_data[name] = form.cleaned_data[name] - - obj.full_clean() - obj.save() - updated_objects.append(obj) - - # Add/remove tags - if form.cleaned_data.get('add_tags', None): - obj.tags.add(*form.cleaned_data['add_tags']) - if form.cleaned_data.get('remove_tags', None): - obj.tags.remove(*form.cleaned_data['remove_tags']) - - return updated_objects - - def get(self, request): - return redirect(self.get_return_url(request)) - - def post(self, request, **kwargs): - logger = logging.getLogger('netbox.views.BulkEditView') - model = self.queryset.model - - # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. - if request.POST.get('_all') and self.filterset is not None: - pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True)).qs - else: - pk_list = request.POST.getlist('pk') - - # Include the PK list as initial data for the form - initial_data = {'pk': pk_list} - - # Check for other contextual data needed for the form. We avoid passing all of request.GET because the - # filter values will conflict with the bulk edit form fields. - # TODO: Find a better way to accomplish this - if 'device' in request.GET: - initial_data['device'] = request.GET.get('device') - elif 'device_type' in request.GET: - initial_data['device_type'] = request.GET.get('device_type') - elif 'virtual_machine' in request.GET: - initial_data['virtual_machine'] = request.GET.get('virtual_machine') - - if '_apply' in request.POST: - form = self.form(model, request.POST, initial=initial_data) - restrict_form_fields(form, request.user) - - if form.is_valid(): - logger.debug("Form validation was successful") - - try: - - with transaction.atomic(): - updated_objects = self._update_objects(form, request) - - # Enforce object-level permissions - object_count = self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() - if object_count != len(updated_objects): - raise PermissionsViolation - - if updated_objects: - msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}' - logger.info(msg) - messages.success(self.request, msg) - - return redirect(self.get_return_url(request)) - - except ValidationError as e: - messages.error(self.request, ", ".join(e.messages)) - clear_webhooks.send(sender=self) - - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) - clear_webhooks.send(sender=self) - - else: - logger.debug("Form validation failed") - - else: - form = self.form(model, initial=initial_data) - restrict_form_fields(form, request.user) - - # Retrieve objects being edited - table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) - if not table.rows: - messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural)) - return redirect(self.get_return_url(request)) - - return render(request, self.template_name, { - 'form': form, - 'table': table, - 'obj_type_plural': model._meta.verbose_name_plural, - 'return_url': self.get_return_url(request), - }) - - -class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - An extendable view for renaming objects in bulk. - - queryset: QuerySet of objects being renamed - template_name: The name of the template - """ - queryset = None - template_name = 'generic/object_bulk_rename.html' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Create a new Form class from BulkRenameForm - class _Form(BulkRenameForm): - pk = ModelMultipleChoiceField( - queryset=self.queryset, - widget=MultipleHiddenInput() - ) - - self.form = _Form - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'change') - - def _rename_objects(self, form, selected_objects): - renamed_pks = [] - - for obj in selected_objects: - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - find = form.cleaned_data['find'] - replace = form.cleaned_data['replace'] - if form.cleaned_data['use_regex']: - try: - obj.new_name = re.sub(find, replace, obj.name) - # Catch regex group reference errors - except re.error: - obj.new_name = obj.name - else: - obj.new_name = obj.name.replace(find, replace) - renamed_pks.append(obj.pk) - - return renamed_pks - - def post(self, request): - logger = logging.getLogger('netbox.views.BulkRenameView') - - if '_preview' in request.POST or '_apply' in request.POST: - form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.queryset.filter(pk__in=form.initial['pk']) - - if form.is_valid(): - try: - with transaction.atomic(): - renamed_pks = self._rename_objects(form, selected_objects) - - if '_apply' in request.POST: - for obj in selected_objects: - obj.name = obj.new_name - obj.save() - - # Enforce constrained permissions - if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects): - raise PermissionsViolation - - model_name = self.queryset.model._meta.verbose_name_plural - messages.success(request, f"Renamed {len(selected_objects)} {model_name}") - return redirect(self.get_return_url(request)) - - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) - clear_webhooks.send(sender=self) - - else: - form = self.form(initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.queryset.filter(pk__in=form.initial['pk']) - - return render(request, self.template_name, { - 'form': form, - 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, - 'selected_objects': selected_objects, - 'return_url': self.get_return_url(request), - }) - - -class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - Delete objects in bulk. - - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being deleted - form: The form class used to delete objects in bulk - template_name: The name of the template - """ - queryset = None - filterset = None - table = None - form = None - template_name = 'generic/object_bulk_delete.html' - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'delete') - - def get(self, request): - return redirect(self.get_return_url(request)) - - def post(self, request, **kwargs): - logger = logging.getLogger('netbox.views.BulkDeleteView') - model = self.queryset.model - - # Are we deleting *all* objects in the queryset or just a selected subset? - if request.POST.get('_all'): - qs = model.objects.all() - if self.filterset is not None: - qs = self.filterset(request.GET, qs).qs - pk_list = qs.only('pk').values_list('pk', flat=True) - else: - pk_list = [int(pk) for pk in request.POST.getlist('pk')] - - form_cls = self.get_form() - - if '_confirm' in request.POST: - form = form_cls(request.POST) - if form.is_valid(): - logger.debug("Form validation was successful") - - # Delete objects - queryset = self.queryset.filter(pk__in=pk_list) - deleted_count = queryset.count() - try: - for obj in queryset: - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - obj.delete() - except ProtectedError as e: - logger.info("Caught ProtectedError while attempting to delete objects") - handle_protectederror(queryset, request, e) - return redirect(self.get_return_url(request)) - - msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}" - logger.info(msg) - messages.success(request, msg) - return redirect(self.get_return_url(request)) - - else: - logger.debug("Form validation failed") - - else: - form = form_cls(initial={ - 'pk': pk_list, - 'return_url': self.get_return_url(request), - }) - - # Retrieve objects being deleted - table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) - if not table.rows: - messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural)) - return redirect(self.get_return_url(request)) - - return render(request, self.template_name, { - 'form': form, - 'obj_type_plural': model._meta.verbose_name_plural, - 'table': table, - 'return_url': self.get_return_url(request), - }) - - def get_form(self): - """ - Provide a standard bulk delete form if none has been specified for the view - """ - class BulkDeleteForm(ConfirmationForm): - pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) - - if self.form: - return self.form - - return BulkDeleteForm - - # # Device/VirtualMachine components # @@ -1342,111 +768,3 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View clear_webhooks.send(sender=self) return None - - -class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): - """ - Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. - """ - parent_model = None - parent_field = None - form = None - queryset = None - model_form = None - filterset = None - table = None - template_name = 'generic/object_bulk_add_component.html' - - def get_required_permission(self): - return f'dcim.add_{self.queryset.model._meta.model_name}' - - def post(self, request): - logger = logging.getLogger('netbox.views.BulkComponentCreateView') - parent_model_name = self.parent_model._meta.verbose_name_plural - model_name = self.queryset.model._meta.verbose_name_plural - - # Are we editing *all* objects in the queryset or just a selected subset? - if request.POST.get('_all') and self.filterset is not None: - pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs] - else: - pk_list = [int(pk) for pk in request.POST.getlist('pk')] - - selected_objects = self.parent_model.objects.filter(pk__in=pk_list) - if not selected_objects: - messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural)) - return redirect(self.get_return_url(request)) - table = self.table(selected_objects) - - if '_create' in request.POST: - form = self.form(request.POST) - - if form.is_valid(): - logger.debug("Form validation was successful") - - new_components = [] - data = deepcopy(form.cleaned_data) - - try: - with transaction.atomic(): - - for obj in data['pk']: - - names = data['name_pattern'] - labels = data['label_pattern'] if 'label_pattern' in data else None - for i, name in enumerate(names): - label = labels[i] if labels else None - - component_data = { - self.parent_field: obj.pk, - 'name': name, - 'label': label - } - component_data.update(data) - component_form = self.model_form(component_data) - if component_form.is_valid(): - instance = component_form.save() - logger.debug(f"Created {instance} on {instance.parent_object}") - new_components.append(instance) - else: - for field, errors in component_form.errors.as_data().items(): - for e in errors: - form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) - - # Enforce object-level permissions - if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components): - raise PermissionsViolation - - except IntegrityError: - clear_webhooks.send(sender=self) - - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) - clear_webhooks.send(sender=self) - - if not form.errors: - msg = "Added {} {} to {} {}.".format( - len(new_components), - model_name, - len(form.cleaned_data['pk']), - parent_model_name - ) - logger.info(msg) - messages.success(request, msg) - - return redirect(self.get_return_url(request)) - - else: - logger.debug("Form validation failed") - - else: - form = self.form(initial={'pk': pk_list}) - - return render(request, self.template_name, { - 'form': form, - 'parent_model_name': parent_model_name, - 'model_name': model_name, - 'table': table, - 'return_url': self.get_return_url(request), - }) From e529d7fd3b721c3cf5b2e5c2c61c4df45aa474d5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 09:35:57 -0500 Subject: [PATCH 016/271] Add ModuleBay and ModuleBayTemplate models --- netbox/dcim/api/nested_serializers.py | 19 +++ netbox/dcim/api/serializers.py | 22 +++ netbox/dcim/api/urls.py | 2 + netbox/dcim/api/views.py | 17 +- netbox/dcim/filtersets.py | 30 ++++ netbox/dcim/forms/bulk_create.py | 6 + netbox/dcim/forms/bulk_edit.py | 34 +++- netbox/dcim/forms/bulk_import.py | 12 ++ netbox/dcim/forms/filtersets.py | 11 ++ netbox/dcim/forms/models.py | 29 ++++ netbox/dcim/forms/object_create.py | 11 ++ netbox/dcim/forms/object_import.py | 10 ++ netbox/dcim/graphql/schema.py | 6 + netbox/dcim/graphql/types.py | 18 ++ netbox/dcim/migrations/0145_modules.py | 53 ++++++ netbox/dcim/models/__init__.py | 2 + .../dcim/models/device_component_templates.py | 20 ++- netbox/dcim/models/device_components.py | 30 ++-- netbox/dcim/models/devices.py | 3 + netbox/dcim/tables/devices.py | 35 +++- netbox/dcim/tables/devicetypes.py | 16 +- netbox/dcim/tests/test_api.py | 79 +++++++++ netbox/dcim/tests/test_filtersets.py | 155 ++++++++++++++++++ netbox/dcim/tests/test_models.py | 10 ++ netbox/dcim/tests/test_views.py | 109 ++++++++++++ netbox/dcim/urls.py | 23 +++ netbox/dcim/views.py | 121 +++++++++++++- netbox/netbox/navigation_menu.py | 1 + netbox/templates/dcim/device/base.html | 15 ++ netbox/templates/dcim/device/modulebays.html | 43 +++++ netbox/templates/dcim/device_list.html | 7 + netbox/templates/dcim/devicetype/base.html | 11 ++ netbox/templates/dcim/modulebay.html | 69 ++++++++ 33 files changed, 1008 insertions(+), 21 deletions(-) create mode 100644 netbox/dcim/migrations/0145_modules.py create mode 100644 netbox/templates/dcim/device/modulebays.html create mode 100644 netbox/templates/dcim/modulebay.html diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 1fdde78d7..e050a22db 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -20,6 +20,8 @@ __all__ = [ 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', 'NestedManufacturerSerializer', + 'NestedModuleBaySerializer', + 'NestedModuleBayTemplateSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', @@ -195,6 +197,14 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedModuleBayTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + + class Meta: + model = models.ModuleBayTemplate + fields = ['id', 'url', 'display', 'name'] + + class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') @@ -298,6 +308,15 @@ class NestedFrontPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] +class NestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + # module = NestedModuleSerializer(read_only=True) + + class Meta: + model = models.DeviceBay + fields = ['id', 'url', 'display', 'name'] + + class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer(read_only=True) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 113c71745..1d1294b7a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -409,6 +409,15 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): ] +class ModuleBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + device_type = NestedDeviceTypeSerializer() + + class Meta: + model = ModuleBayTemplate + fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] + + class DeviceBayTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') device_type = NestedDeviceTypeSerializer() @@ -707,6 +716,19 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): ] +class ModuleBaySerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + device = NestedDeviceSerializer() + # installed_module = NestedModuleSerializer(required=False, allow_null=True) + + class Meta: + model = ModuleBay + fields = [ + 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', + ] + + class DeviceBaySerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 491f4e7f2..bf68106f5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -28,6 +28,7 @@ router.register('power-outlet-templates', views.PowerOutletTemplateViewSet) router.register('interface-templates', views.InterfaceTemplateViewSet) router.register('front-port-templates', views.FrontPortTemplateViewSet) router.register('rear-port-templates', views.RearPortTemplateViewSet) +router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) # Devices @@ -43,6 +44,7 @@ router.register('power-outlets', views.PowerOutletViewSet) router.register('interfaces', views.InterfaceViewSet) router.register('front-ports', views.FrontPortViewSet) router.register('rear-ports', views.RearPortViewSet) +router.register('module-bays', views.ModuleBayViewSet) router.register('device-bays', views.DeviceBayViewSet) router.register('inventory-items', views.InventoryItemViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f359f0f24..25dfda360 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -329,6 +329,12 @@ class RearPortTemplateViewSet(ModelViewSet): filterset_class = filtersets.RearPortTemplateFilterSet +class ModuleBayTemplateViewSet(ModelViewSet): + queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer') + serializer_class = serializers.ModuleBayTemplateSerializer + filterset_class = filtersets.ModuleBayTemplateFilterSet + + class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer @@ -569,15 +575,22 @@ class RearPortViewSet(PassThroughPortMixin, ModelViewSet): brief_prefetch_fields = ['device'] +class ModuleBayViewSet(ModelViewSet): + queryset = ModuleBay.objects.prefetch_related('tags') + serializer_class = serializers.ModuleBaySerializer + filterset_class = filtersets.ModuleBayFilterSet + brief_prefetch_fields = ['device'] + + class DeviceBayViewSet(ModelViewSet): - queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') + queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags') serializer_class = serializers.DeviceBaySerializer filterset_class = filtersets.DeviceBayFilterSet brief_prefetch_fields = ['device'] class InventoryItemViewSet(ModelViewSet): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filtersets.InventoryItemFilterSet brief_prefetch_fields = ['device'] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 8b1369be9..d4c5f8e5a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -41,6 +41,8 @@ __all__ = ( 'InventoryItemFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', + 'ModuleBayFilterSet', + 'ModuleBayTemplateFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -447,6 +449,10 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', @@ -490,6 +496,9 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): rearporttemplates__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebaytemplates__isnull=value) + def _device_bays(self, queryset, name, value): return queryset.exclude(devicebaytemplates__isnull=value) @@ -576,6 +585,13 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentF fields = ['id', 'name', 'type', 'color', 'positions'] +class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + + class Meta: + model = ModuleBayTemplate + fields = ['id', 'name'] + + class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: @@ -760,6 +776,10 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', @@ -811,6 +831,9 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex rearports__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebays__isnull=value) + def _device_bays(self, queryset, name, value): return queryset.exclude(devicebays__isnull=value) @@ -1104,6 +1127,13 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] +class ModuleBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): + + class Meta: + model = ModuleBay + fields = ['id', 'name', 'label', 'description'] + + class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): class Meta: diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 16e860c38..8eae46111 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -13,6 +13,7 @@ __all__ = ( # 'FrontPortBulkCreateForm', 'InterfaceBulkCreateForm', 'InventoryItemBulkCreateForm', + 'ModuleBayBulkCreateForm', 'PowerOutletBulkCreateForm', 'PowerPortBulkCreateForm', 'RearPortBulkCreateForm', @@ -95,6 +96,11 @@ class RearPortBulkCreateForm( field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') +class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): + model = ModuleBay + field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + + class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): model = DeviceBay field_order = ('name_pattern', 'label_pattern', 'description', 'tags') diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index a40396e98..02492c630 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -7,7 +7,6 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX from ipam.models import VLAN, ASN from tenancy.models import Tenant from utilities.forms import ( @@ -33,6 +32,8 @@ __all__ = ( 'InventoryItemBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', + 'ModuleBayBulkEditForm', + 'ModuleBayTemplateBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -823,6 +824,23 @@ class RearPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description',) +class ModuleBayTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ModuleBayTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'description') + + class DeviceBayTemplateBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceBayTemplate.objects.all(), @@ -1076,6 +1094,20 @@ class RearPortBulkEditForm( nullable_fields = ['label', 'description'] +class ModuleBayBulkEditForm( + form_from_model(DeviceBay, ['label', 'description']), + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=ModuleBay.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + class Meta: + nullable_fields = ['label', 'description'] + + class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), AddRemoveTagsForm, diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 081f8d466..6092b3d41 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -26,6 +26,7 @@ __all__ = ( 'InventoryItemCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', + 'ModuleBayCSVForm', 'PlatformCSVForm', 'PowerFeedCSVForm', 'PowerOutletCSVForm', @@ -678,6 +679,17 @@ class RearPortCSVForm(CustomFieldModelCSVForm): } +class ModuleBayCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleBay + fields = ('device', 'name', 'label', 'description') + + class DeviceBayCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index a1d996b2c..e134adace 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -29,6 +29,7 @@ __all__ = ( 'InventoryItemFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', + 'ModuleBayFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -970,6 +971,16 @@ class RearPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) +class ModuleBayFilterForm(DeviceComponentFilterForm): + model = ModuleBay + field_groups = [ + ['q', 'tag'], + ['name', 'label'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], + ] + tag = TagFilterField(model) + + class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay field_groups = [ diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index d16cf3dd1..2fcd23211 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -39,6 +39,8 @@ __all__ = ( 'InventoryItemForm', 'LocationForm', 'ManufacturerForm', + 'ModuleBayForm', + 'ModuleBayTemplateForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -984,6 +986,17 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): } +class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate @@ -1222,6 +1235,22 @@ class RearPortForm(CustomFieldModelForm): } +class ModuleBayForm(CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ModuleBay + fields = [ + 'device', 'name', 'label', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + class DeviceBayForm(CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 92b92ef3e..bf9060225 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -25,6 +25,8 @@ __all__ = ( 'InterfaceCreateForm', 'InterfaceTemplateCreateForm', 'InventoryItemCreateForm', + 'ModuleBayCreateForm', + 'ModuleBayTemplateCreateForm', 'PowerOutletCreateForm', 'PowerOutletTemplateCreateForm', 'PowerPortCreateForm', @@ -327,6 +329,10 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm): ) +class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): + field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') + + class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') @@ -619,6 +625,11 @@ class RearPortCreateForm(ComponentCreateForm): ) +class ModuleBayCreateForm(ComponentCreateForm): + model = ModuleBay + field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') + + class DeviceBayCreateForm(ComponentCreateForm): model = DeviceBay field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 03f040a00..49924b623 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -11,6 +11,7 @@ __all__ = ( 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', + 'ModuleBayTemplateImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', @@ -139,6 +140,15 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): ] +class ModuleBayTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'description', + ] + + class DeviceBayTemplateImportForm(ComponentTemplateImportForm): class Meta: diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 13e0c20ec..60b7526bd 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -56,6 +56,12 @@ class DCIMQuery(graphene.ObjectType): manufacturer = ObjectField(ManufacturerType) manufacturer_list = ObjectListField(ManufacturerType) + module_bay = ObjectField(ModuleBayType) + module_bay_list = ObjectListField(ModuleBayType) + + module_bay_template = ObjectField(ModuleBayTemplateType) + module_bay_template_list = ObjectListField(ModuleBayTemplateType) + platform = ObjectField(PlatformType) platform_list = ObjectListField(PlatformType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8ce10979e..355c14dc3 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -27,6 +27,8 @@ __all__ = ( 'InventoryItemType', 'LocationType', 'ManufacturerType', + 'ModuleBayType', + 'ModuleBayTemplateType', 'PlatformType', 'PowerFeedType', 'PowerOutletType', @@ -254,6 +256,22 @@ class ManufacturerType(OrganizationalObjectType): filterset_class = filtersets.ManufacturerFilterSet +class ModuleBayType(ComponentObjectType): + + class Meta: + model = models.ModuleBay + fields = '__all__' + filterset_class = filtersets.ModuleBayFilterSet + + +class ModuleBayTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.ModuleBayTemplate + fields = '__all__' + filterset_class = filtersets.ModuleBayTemplateFilterSet + + class PlatformType(OrganizationalObjectType): class Meta: diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py new file mode 100644 index 000000000..c469e059c --- /dev/null +++ b/netbox/dcim/migrations/0145_modules.py @@ -0,0 +1,53 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0066_customfield_name_validation'), + ('dcim', '0144_site_remove_deprecated_fields'), + ] + + operations = [ + migrations.CreateModel( + name='ModuleBayTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('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)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebaytemplates', to='dcim.devicetype')), + ], + options={ + 'ordering': ('device_type', '_name'), + 'unique_together': {('device_type', 'name')}, + }, + ), + migrations.CreateModel( + name='ModuleBay', + 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)), + ('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)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('device', '_name'), + 'unique_together': {('device', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 58a3e1de5..86e49c42e 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -27,6 +27,8 @@ __all__ = ( 'InventoryItem', 'Location', 'Manufacturer', + 'ModuleBay', + 'ModuleBayTemplate', 'Platform', 'PowerFeed', 'PowerOutlet', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 42e453669..c8ab8f5f0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -9,7 +9,7 @@ from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField from utilities.ordering import naturalize_interface from .device_components import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, ModuleBay, PowerOutlet, PowerPort, RearPort, ) @@ -19,6 +19,7 @@ __all__ = ( 'DeviceBayTemplate', 'FrontPortTemplate', 'InterfaceTemplate', + 'ModuleBayTemplate', 'PowerOutletTemplate', 'PowerPortTemplate', 'RearPortTemplate', @@ -360,6 +361,23 @@ class RearPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') +class ModuleBayTemplate(ComponentTemplateModel): + """ + A template for a ModuleBay to be created for a new parent Device. + """ + class Meta: + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') + + def instantiate(self, device): + return ModuleBay( + device=device, + name=self.name, + label=self.label + ) + + @extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e105bd804..08e069239 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -30,6 +30,7 @@ __all__ = ( 'FrontPort', 'Interface', 'InventoryItem', + 'ModuleBay', 'PathEndpoint', 'PowerOutlet', 'PowerPort', @@ -229,7 +230,7 @@ class PathEndpoint(models.Model): # -# Console ports +# Console components # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @@ -260,10 +261,6 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -# -# Console server ports -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): """ @@ -293,7 +290,7 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): # -# Power ports +# Power components # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @@ -389,10 +386,6 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint): } -# -# Power outlets -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): """ @@ -866,9 +859,24 @@ class RearPort(ComponentModel, LinkTermination): # -# Device bays +# Bays # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ModuleBay(ComponentModel): + """ + An empty space within a Device which can house a child device + """ + 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}) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceBay(ComponentModel): """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 24eeb7ac3..18c0fe9de 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -786,6 +786,9 @@ class Device(PrimaryModel, ConfigContextModel): FrontPort.objects.bulk_create( [x.instantiate(self) for x in self.device_type.frontporttemplates.all()] ) + ModuleBay.objects.bulk_create( + [x.instantiate(self) for x in self.device_type.modulebaytemplates.all()] + ) DeviceBay.objects.bulk_create( [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()] ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f0e9c9bb0..df1d79aa4 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -2,8 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, - PowerOutlet, PowerPort, RearPort, VirtualChassis, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, ModuleBay, + Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) from tenancy.tables import TenantColumn from utilities.tables import ( @@ -25,6 +25,7 @@ __all__ = ( 'DeviceImportTable', 'DeviceInterfaceTable', 'DeviceInventoryItemTable', + 'DeviceModuleBayTable', 'DevicePowerPortTable', 'DevicePowerOutletTable', 'DeviceRearPortTable', @@ -33,6 +34,7 @@ __all__ = ( 'FrontPortTable', 'InterfaceTable', 'InventoryItemTable', + 'ModuleBayTable', 'PlatformTable', 'PowerOutletTable', 'PowerPortTable', @@ -716,6 +718,35 @@ class DeviceDeviceBayTable(DeviceBayTable): ) +class ModuleBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + tags = TagColumn( + url_name='dcim:modulebay_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ('pk', 'id', 'name', 'device', 'label', 'description', 'tags') + default_columns = ('pk', 'name', 'device', 'label', 'description') + + +class DeviceModuleBayTable(ModuleBayTable): + actions = ButtonsColumn( + model=ModuleBay, + buttons=('edit', 'delete') + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ('pk', 'id', 'name', 'label', 'description', 'tags', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'actions') + + class InventoryItemTable(DeviceComponentTable): device = tables.Column( linkify={ diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f932b7994..6fc038542 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -2,7 +2,7 @@ import django_tables2 as tables from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, - Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, + Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, @@ -16,6 +16,7 @@ __all__ = ( 'FrontPortTemplateTable', 'InterfaceTemplateTable', 'ManufacturerTable', + 'ModuleBayTemplateTable', 'PowerOutletTemplateTable', 'PowerPortTemplateTable', 'RearPortTemplateTable', @@ -207,6 +208,19 @@ class RearPortTemplateTable(ComponentTemplateTable): empty_text = "None" +class ModuleBayTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=ModuleBayTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_modulebays' + ) + + class Meta(ComponentTemplateTable.Meta): + model = ModuleBayTemplate + fields = ('pk', 'name', 'label', 'description', 'actions') + empty_text = "None" + + class DeviceBayTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=DeviceBayTemplate, diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bc6b18ead..2f68b0fbf 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -778,6 +778,46 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): ] +class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase): + model = ModuleBayTemplate + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, + model='Device Type 1', + slug='device-type-1', + subdevice_role=SubdeviceRoleChoices.ROLE_PARENT + ) + + module_bay_templates = ( + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'), + ) + ModuleBayTemplate.objects.bulk_create(module_bay_templates) + + cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Module Bay Template 4', + }, + { + 'device_type': devicetype.pk, + 'name': 'Module Bay Template 5', + }, + { + 'device_type': devicetype.pk, + 'name': 'Module Bay Template 6', + }, + ] + + class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate brief_fields = ['display', 'id', 'name', 'url'] @@ -1369,6 +1409,45 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): ] +class ModuleBayTest(APIViewTestCases.APIViewTestCase): + model = ModuleBay + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + site = Site.objects.create(name='Site 1', slug='site-1') + devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') + + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device = Device.objects.create(device_type=device_type, device_role=devicerole, name='Device 1', site=site) + + device_bays = ( + ModuleBay(device=device, name='Device Bay 1'), + ModuleBay(device=device, name='Device Bay 2'), + ModuleBay(device=device, name='Device Bay 3'), + ) + ModuleBay.objects.bulk_create(device_bays) + + cls.create_data = [ + { + 'device': device.pk, + 'name': 'Device Bay 4', + }, + { + 'device': device.pk, + 'name': 'Device Bay 5', + }, + { + 'device': device.pk, + 'name': 'Device Bay 6', + }, + ] + + class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay brief_fields = ['device', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index a187c8881..c35739320 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -678,6 +678,10 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPortTemplate(device_type=device_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), FrontPortTemplate(device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), )) + ModuleBayTemplate.objects.bulk_create(( + ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'), + ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'), + )) DeviceBayTemplate.objects.bulk_create(( DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'), DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), @@ -762,6 +766,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'device_bays': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_module_bays(self): + params = {'module_bays': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'module_bays': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() @@ -1036,6 +1046,38 @@ class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleBayTemplate.objects.all() + filterset = ModuleBayTemplateFilterSet + + @classmethod + def setUpTestData(cls): + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + + device_types = ( + DeviceType(manufacturer=manufacturer, model='Model 1', slug='model-1'), + DeviceType(manufacturer=manufacturer, model='Model 2', slug='model-2'), + DeviceType(manufacturer=manufacturer, model='Model 3', slug='model-3'), + ) + DeviceType.objects.bulk_create(device_types) + + ModuleBayTemplate.objects.bulk_create(( + ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'), + ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'), + ModuleBayTemplate(device_type=device_types[2], name='Module Bay 3'), + )) + + def test_name(self): + params = {'name': ['Module Bay 1', 'Module Bay 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_devicetype_id(self): + device_types = DeviceType.objects.all()[:2] + params = {'devicetype_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceBayTemplate.objects.all() filterset = DeviceBayTemplateFilterSet @@ -1280,6 +1322,10 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), )) + ModuleBay.objects.bulk_create(( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + )) DeviceBay.objects.bulk_create(( DeviceBay(device=devices[0], name='Device Bay 1'), DeviceBay(device=devices[1], name='Device Bay 2'), @@ -1465,6 +1511,12 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'pass_through_ports': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_module_bays(self): + params = {'module_bays': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'module_bays': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_device_bays(self): params = {'device_bays': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2508,6 +2560,109 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleBay.objects.all() + filterset = ModuleBayFilterSet + + @classmethod + def setUpTestData(cls): + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + + sites = Site.objects.bulk_create(( + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), + Site(name='Site X', slug='site-x'), + )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') + device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + + devices = ( + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]), + ) + Device.objects.bulk_create(devices) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'), + ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'), + ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'), + ) + ModuleBay.objects.bulk_create(module_bays) + + def test_name(self): + params = {'name': ['Module Bay 1', 'Module Bay 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['First', 'Second']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceBay.objects.all() filterset = DeviceBayFilterSet diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 1042057de..8566f969b 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -308,6 +308,11 @@ class DeviceTestCase(TestCase): rear_port_position=2 ).save() + ModuleBayTemplate( + device_type=self.device_type, + name='Module Bay 1' + ).save() + DeviceBayTemplate( device_type=self.device_type, name='Device Bay 1' @@ -371,6 +376,11 @@ class DeviceTestCase(TestCase): rear_port_position=2 ) + ModuleBay.objects.get( + device=d, + name='Module Bay 1' + ) + DeviceBay.objects.get( device=d, name='Device Bay 1' diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4706cdc6a..7f93c10a2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -554,6 +554,19 @@ class DeviceTypeTestCase( url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_modulebays(self): + devicetype = DeviceType.objects.first() + module_bays = ( + ModuleBayTemplate(device_type=devicetype, name='Module Bay 1'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay 2'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay 3'), + ) + ModuleBayTemplate.objects.bulk_create(module_bays) + + url = reverse('dcim:devicetype_modulebays', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_devicetype_devicebays(self): devicetype = DeviceType.objects.first() @@ -638,6 +651,10 @@ front-ports: - name: Front Port 3 type: 8p8c rear_port: Rear Port 3 +module-bays: + - name: Module Bay 1 + - name: Module Bay 2 + - name: Module Bay 3 device-bays: - name: Device Bay 1 - name: Device Bay 2 @@ -658,6 +675,7 @@ device-bays: 'dcim.add_interfacetemplate', 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', + 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', ) @@ -710,6 +728,10 @@ device-bays: self.assertEqual(fp1.rear_port, rp1) self.assertEqual(fp1.rear_port_position, 1) + self.assertEqual(dt.modulebaytemplates.count(), 3) + db1 = ModuleBayTemplate.objects.first() + self.assertEqual(db1.name, 'Module Bay 1') + self.assertEqual(dt.devicebaytemplates.count(), 3) db1 = DeviceBayTemplate.objects.first() self.assertEqual(db1.name, 'Device Bay 1') @@ -1011,6 +1033,39 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase } +class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = ModuleBayTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + ModuleBayTemplate.objects.bulk_create(( + ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'), + ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'), + ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Module Bay Template X', + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Module Bay Template [4-6]', + } + + cls.bulk_edit_data = { + 'description': 'Foo bar', + } + + class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate @@ -1307,6 +1362,19 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('dcim:device_frontports', kwargs={'pk': device.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_device_modulebays(self): + device = Device.objects.first() + device_bays = ( + ModuleBay(device=device, name='Module Bay 1'), + ModuleBay(device=device, name='Module Bay 2'), + ModuleBay(device=device, name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(device_bays) + + url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_device_devicebays(self): device = Device.objects.first() @@ -1807,6 +1875,47 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): self.assertHttpStatus(response, 200) +class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = ModuleBay + + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + + ModuleBay.objects.bulk_create([ + ModuleBay(device=device, name='Module Bay 1'), + ModuleBay(device=device, name='Module Bay 2'), + ModuleBay(device=device, name='Module Bay 3'), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': device.pk, + 'name': 'Module Bay X', + 'description': 'A device bay', + 'tags': [t.pk for t in tags], + } + + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Module Bay [4-6]', + 'description': 'A module bay', + 'tags': [t.pk for t in tags], + } + + cls.bulk_edit_data = { + 'description': 'New description', + } + + cls.csv_data = ( + "device,name", + "Device 1,Module Bay 4", + "Device 1,Module Bay 5", + "Device 1,Module Bay 6", + ) + + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 11665f22a..dbde8e348 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -113,6 +113,7 @@ urlpatterns = [ 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//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), @@ -183,6 +184,14 @@ urlpatterns = [ path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), + # Device bay templates + path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'), + path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'), + path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'), + path('module-bay-templates/delete/', views.ModuleBayTemplateBulkDeleteView.as_view(), name='modulebaytemplate_bulk_delete'), + path('module-bay-templates//edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'), + path('module-bay-templates//delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'), + # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), @@ -222,6 +231,7 @@ urlpatterns = [ 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'), @@ -343,6 +353,19 @@ urlpatterns = [ path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), + # Module bays + path('module-bays/', views.ModuleBayListView.as_view(), name='modulebay_list'), + path('module-bays/add/', views.ModuleBayCreateView.as_view(), name='modulebay_add'), + path('module-bays/import/', views.ModuleBayBulkImportView.as_view(), name='modulebay_import'), + path('module-bays/edit/', views.ModuleBayBulkEditView.as_view(), name='modulebay_bulk_edit'), + path('module-bays/rename/', views.ModuleBayBulkRenameView.as_view(), name='modulebay_bulk_rename'), + path('module-bays/delete/', views.ModuleBayBulkDeleteView.as_view(), name='modulebay_bulk_delete'), + 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('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'), + # Device bays path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4ec31e60c..fed6ea31d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -30,9 +30,9 @@ from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, - PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - SiteGroup, VirtualChassis, + InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, PathEndpoint, Platform, PowerFeed, PowerOutlet, + PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, + RearPortTemplate, Region, Site, SiteGroup, VirtualChassis, ) @@ -836,6 +836,12 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView): filterset = filtersets.RearPortTemplateFilterSet +class DeviceTypeModuleBaysView(DeviceTypeComponentsView): + child_model = ModuleBayTemplate + table = tables.ModuleBayTemplateTable + filterset = filtersets.ModuleBayTemplateFilterSet + + class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): child_model = DeviceBayTemplate table = tables.DeviceBayTemplateTable @@ -861,6 +867,7 @@ class DeviceTypeImportView(generic.ObjectImportView): 'dcim.add_interfacetemplate', 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', + 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', ] queryset = DeviceType.objects.all() @@ -873,6 +880,7 @@ class DeviceTypeImportView(generic.ObjectImportView): ('interfaces', forms.InterfaceTemplateImportForm), ('rear-ports', forms.RearPortTemplateImportForm), ('front-ports', forms.FrontPortTemplateImportForm), + ('module-bays', forms.ModuleBayTemplateImportForm), ('device-bays', forms.DeviceBayTemplateImportForm), )) @@ -1132,6 +1140,40 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.RearPortTemplateTable +# +# Module bay templates +# + +class ModuleBayTemplateCreateView(generic.ComponentCreateView): + queryset = ModuleBayTemplate.objects.all() + form = forms.ModuleBayTemplateCreateForm + model_form = forms.ModuleBayTemplateForm + + +class ModuleBayTemplateEditView(generic.ObjectEditView): + queryset = ModuleBayTemplate.objects.all() + model_form = forms.ModuleBayTemplateForm + + +class ModuleBayTemplateDeleteView(generic.ObjectDeleteView): + queryset = ModuleBayTemplate.objects.all() + + +class ModuleBayTemplateBulkEditView(generic.BulkEditView): + queryset = ModuleBayTemplate.objects.all() + table = tables.ModuleBayTemplateTable + form = forms.ModuleBayTemplateBulkEditForm + + +class ModuleBayTemplateBulkRenameView(generic.BulkRenameView): + queryset = ModuleBayTemplate.objects.all() + + +class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleBayTemplate.objects.all() + table = tables.ModuleBayTemplateTable + + # # Device bay templates # @@ -1388,6 +1430,13 @@ class DeviceRearPortsView(DeviceComponentsView): template_name = 'dcim/device/rearports.html' +class DeviceModuleBaysView(DeviceComponentsView): + child_model = ModuleBay + table = tables.DeviceModuleBayTable + filterset = filtersets.ModuleBayFilterSet + template_name = 'dcim/device/modulebays.html' + + class DeviceDeviceBaysView(DeviceComponentsView): child_model = DeviceBay table = tables.DeviceDeviceBayTable @@ -1978,6 +2027,61 @@ class RearPortBulkDeleteView(generic.BulkDeleteView): table = tables.RearPortTable +# +# Module bays +# + +class ModuleBayListView(generic.ObjectListView): + queryset = ModuleBay.objects.all() + filterset = filtersets.ModuleBayFilterSet + filterset_form = forms.ModuleBayFilterForm + table = tables.ModuleBayTable + action_buttons = ('import', 'export') + + +class ModuleBayView(generic.ObjectView): + queryset = ModuleBay.objects.all() + + +class ModuleBayCreateView(generic.ComponentCreateView): + queryset = ModuleBay.objects.all() + form = forms.ModuleBayCreateForm + model_form = forms.ModuleBayForm + + +class ModuleBayEditView(generic.ObjectEditView): + queryset = ModuleBay.objects.all() + model_form = forms.ModuleBayForm + template_name = 'dcim/device_component_edit.html' + + +class ModuleBayDeleteView(generic.ObjectDeleteView): + queryset = ModuleBay.objects.all() + + +class ModuleBayBulkImportView(generic.BulkImportView): + queryset = ModuleBay.objects.all() + model_form = forms.ModuleBayCSVForm + table = tables.ModuleBayTable + + +class ModuleBayBulkEditView(generic.BulkEditView): + queryset = ModuleBay.objects.all() + filterset = filtersets.ModuleBayFilterSet + table = tables.ModuleBayTable + form = forms.ModuleBayBulkEditForm + + +class ModuleBayBulkRenameView(generic.BulkRenameView): + queryset = ModuleBay.objects.all() + + +class ModuleBayBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleBay.objects.all() + filterset = filtersets.ModuleBayFilterSet + table = tables.ModuleBayTable + + # # Device bays # @@ -2234,6 +2338,17 @@ class DeviceBulkAddRearPortView(generic.BulkComponentCreateView): default_return_url = 'dcim:device_list' +class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView): + parent_model = Device + parent_field = 'device' + form = forms.ModuleBayBulkCreateForm + queryset = ModuleBay.objects.all() + model_form = forms.ModuleBayForm + filterset = filtersets.DeviceFilterSet + table = tables.DeviceTable + default_return_url = 'dcim:device_list' + + class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView): parent_model = Device parent_field = 'device' diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 488fa163d..71be861f8 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -161,6 +161,7 @@ DEVICES_MENU = Menu( get_model_item('dcim', 'consoleserverport', 'Console Server Ports', actions=['import']), get_model_item('dcim', 'powerport', 'Power Ports', actions=['import']), get_model_item('dcim', 'poweroutlet', 'Power Outlets', actions=['import']), + get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']), get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']), get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']), ), diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 13d4bbcbc..80ccb69a2 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -69,6 +69,13 @@ {% endif %} + {% if perms.dcim.add_devicebay %} +
  • + + Module Bays + +
  • + {% endif %} {% if perms.dcim.add_devicebay %}
  • @@ -151,6 +158,14 @@ {% endif %} {% endwith %} + {% with modulebay_count=object.modulebays.count %} + {% if modulebay_count %} +
  • + {% endif %} + {% endwith %} + {% with devicebay_count=object.devicebays.count %} {% if devicebay_count %} {% endif %} + {% if perms.dcim.add_modulebay %} +
  • + +
  • + {% endif %} {% if perms.dcim.add_inventoryitem %}
  • + + + {% endif %} +{% endblock %} + +{% block tab_items %} +
  • + + {% 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/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html new file mode 100644 index 000000000..9930588b8 --- /dev/null +++ b/netbox/templates/dcim/moduletype/component_templates.html @@ -0,0 +1,44 @@ +{% extends 'dcim/moduletype/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} + +{% block content %} + {% if perms.dcim.change_moduletype %} +
    + {% csrf_token %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    + +
    +
    + {% else %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    +
    + {% endif %} +{% endblock content %} From 5bd223a4683bb1ea72cffeb5e7c2ed7b13f65d70 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 13:28:17 -0500 Subject: [PATCH 018/271] Fix YAML import for ModuleTypes --- netbox/dcim/forms/object_import.py | 42 ++++++++++----------- netbox/dcim/views.py | 8 ++++ netbox/netbox/views/generic/object_views.py | 11 +++++- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 9df029386..cc0c7dc41 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -50,23 +50,21 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - def __init__(self, device_type, data=None, *args, **kwargs): - - # Must pass the parent DeviceType on form initialization - data.update({ - 'device_type': device_type.pk, - }) - - super().__init__(data, *args, **kwargs) - def clean_device_type(self): - - data = self.cleaned_data['device_type'] - # Limit fields referencing other components to the parent DeviceType - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type': - field.queryset = field.queryset.filter(device_type=data) + if data := self.cleaned_data['device_type']: + for field_name, field in self.fields.items(): + if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: + field.queryset = field.queryset.filter(device_type=data) + + return data + + def clean_module_type(self): + # Limit fields referencing other components to the parent ModuleType + if data := self.cleaned_data['module_type']: + for field_name, field in self.fields.items(): + if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: + field.queryset = field.queryset.filter(module_type=data) return data @@ -76,7 +74,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -85,7 +83,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -94,7 +92,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] @@ -108,7 +106,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] @@ -120,7 +118,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] @@ -136,7 +134,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', ] @@ -148,7 +146,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description', ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 15e7d2406..f673e64d5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -892,6 +892,10 @@ class DeviceTypeImportView(generic.ObjectImportView): ('device-bays', forms.DeviceBayTemplateImportForm), )) + def prep_related_object_data(self, parent, data): + data.update({'device_type': parent}) + return data + class DeviceTypeBulkEditView(generic.BulkEditView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( @@ -1009,6 +1013,10 @@ class ModuleTypeImportView(generic.ObjectImportView): ('front-ports', forms.FrontPortTemplateImportForm), )) + def prep_related_object_data(self, parent, data): + data.update({'module_type': parent}) + return data + class ModuleTypeBulkEditView(generic.BulkEditView): queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 588b51062..eda3658a6 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -319,6 +319,13 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + def prep_related_object_data(self, parent, data): + """ + Hook to modify the data for related objects before it's passed to the related object form (for example, to + assign a parent object). + """ + return data + def _create_object(self, model_form): # Save the primary object @@ -333,8 +340,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): related_obj_pks = [] for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): - - f = related_object_form(obj, rel_obj_data) + rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) + f = related_object_form(rel_obj_data) for subfield_name, field in f.fields.items(): if subfield_name not in rel_obj_data and hasattr(field, 'initial'): From 7777922bef32663f0817f11fbf1e4ec3676b10c3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 16:12:03 -0500 Subject: [PATCH 019/271] Add Module model --- netbox/dcim/api/nested_serializers.py | 19 ++- netbox/dcim/api/serializers.py | 14 ++ netbox/dcim/api/urls.py | 3 +- netbox/dcim/api/views.py | 10 +- netbox/dcim/filtersets.py | 37 +++++ netbox/dcim/forms/bulk_edit.py | 27 +++ netbox/dcim/forms/bulk_import.py | 30 ++++ netbox/dcim/forms/filtersets.py | 33 ++++ netbox/dcim/forms/models.py | 41 +++++ netbox/dcim/graphql/schema.py | 3 + netbox/dcim/graphql/types.py | 9 + netbox/dcim/migrations/0145_modules.py | 95 ++++++++--- netbox/dcim/models/__init__.py | 1 + .../dcim/models/device_component_templates.py | 46 +++--- netbox/dcim/models/device_components.py | 27 ++- netbox/dcim/models/devices.py | 98 ++++++++++- netbox/dcim/tables/__init__.py | 2 +- netbox/dcim/tables/devices.py | 17 +- netbox/dcim/tables/modules.py | 61 +++++++ netbox/dcim/tables/moduletypes.py | 34 ---- netbox/dcim/tables/template_code.py | 14 ++ netbox/dcim/tests/test_api.py | 63 ++++++- netbox/dcim/tests/test_filtersets.py | 75 ++++++++- netbox/dcim/tests/test_views.py | 69 ++++++++ netbox/dcim/urls.py | 16 +- netbox/dcim/views.py | 55 +++++-- netbox/netbox/navigation_menu.py | 1 + netbox/templates/dcim/device/base.html | 32 ++-- netbox/templates/dcim/devicetype/base.html | 32 ++-- netbox/templates/dcim/module.html | 154 ++++++++++++++++++ 30 files changed, 967 insertions(+), 151 deletions(-) create mode 100644 netbox/dcim/tables/modules.py delete mode 100644 netbox/dcim/tables/moduletypes.py create mode 100644 netbox/templates/dcim/module.html diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index a6e359feb..6ed7c63c6 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -22,6 +22,7 @@ __all__ = [ 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', + 'NestedModuleSerializer', 'NestedModuleTypeSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', @@ -260,6 +261,18 @@ class NestedDeviceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer(read_only=True) + # TODO: Solve circular dependency + # module_bay = NestedModuleBaySerializer(read_only=True) + module_type = NestedModuleTypeSerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type'] + + class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) @@ -325,11 +338,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer): class NestedModuleBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - # module = NestedModuleSerializer(read_only=True) + module = NestedModuleSerializer(read_only=True) class Meta: - model = models.DeviceBay - fields = ['id', 'url', 'display', 'name'] + model = models.ModuleBay + fields = ['id', 'url', 'display', 'module', 'name'] class NestedDeviceBaySerializer(WritableNestedSerializer): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c3d6b5cb4..b58355f32 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -517,6 +517,20 @@ class DeviceSerializer(PrimaryModelSerializer): return data +class ModuleSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer() + module_bay = NestedModuleBaySerializer() + module_type = NestedModuleTypeSerializer() + + class Meta: + model = Module + fields = [ + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + class DeviceWithConfigContextSerializer(DeviceSerializer): config_context = serializers.SerializerMethodField() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 7a866063f..71a768fd5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -32,10 +32,11 @@ router.register('rear-port-templates', views.RearPortTemplateViewSet) router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) -# Devices +# Device/modules router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('modules', views.ModuleViewSet) # Device components router.register('console-ports', views.ConsolePortViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e50f9b1b6..378e697c8 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -377,7 +377,7 @@ class PlatformViewSet(CustomFieldModelViewSet): # -# Devices +# Devices/modules # class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): @@ -526,6 +526,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): return Response(response) +class ModuleViewSet(CustomFieldModelViewSet): + queryset = Module.objects.prefetch_related( + 'device', 'module_bay', 'module_type__manufacturer', 'tags', + ) + serializer_class = serializers.ModuleSerializer + filterset_class = filtersets.ModuleFilterSet + + # # Device components # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b0ff992a7..d91a9b574 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -43,6 +43,7 @@ __all__ = ( 'ManufacturerFilterSet', 'ModuleBayFilterSet', 'ModuleBayTemplateFilterSet', + 'ModuleFilterSet', 'ModuleTypeFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', @@ -924,6 +925,42 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex return queryset.exclude(devicebays__isnull=value) +class ModuleFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + tag = TagFilter() + + class Meta: + model = Module + fields = ['id', 'serial', 'asset_tag'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(serial__icontains=value.strip()) | + Q(asset_tag__icontains=value.strip()) | + Q(comments__icontains=value) + ).distinct() + + class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 360fb81cb..378620180 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -32,6 +32,7 @@ __all__ = ( 'InventoryItemBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', + 'ModuleBulkEditForm', 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', 'ModuleTypeBulkEditForm', @@ -473,6 +474,32 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] +class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Module.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + + class Meta: + nullable_fields = ['serial'] + + class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cable.objects.all(), diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 6092b3d41..8f5ba25b7 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -26,6 +26,7 @@ __all__ = ( 'InventoryItemCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', + 'ModuleCSVForm', 'ModuleBayCSVForm', 'PlatformCSVForm', 'PowerFeedCSVForm', @@ -400,6 +401,35 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) +class ModuleCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + module_bay = CSVModelChoiceField( + queryset=ModuleBay.objects.all(), + to_field_name='name' + ) + module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name='model' + ) + + class Meta: + model = Module + fields = ( + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit module_bay queryset by assigned device + params = {f"device__{self.fields['device'].to_field_name}": data.get('device')} + self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params) + + class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 5e8b333b9..29c09c7f7 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -29,6 +29,8 @@ __all__ = ( 'InventoryItemFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', + 'ModuleFilterForm', + 'ModuleFilterForm', 'ModuleBayFilterForm', 'ModuleTypeFilterForm', 'PlatformFilterForm', @@ -645,6 +647,37 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi tag = TagFilterField(model) +class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): + model = Module + field_groups = [ + ['q', 'tag'], + ['manufacturer_id', 'module_type_id'], + ['serial', 'asset_tag'], + ] + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + module_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Type'), + fetch_trigger='open' + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + tag = TagFilterField(model) + + class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = VirtualChassis field_groups = [ diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index ae3cfeaef..672c54c68 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -39,6 +39,7 @@ __all__ = ( 'InventoryItemForm', 'LocationForm', 'ManufacturerForm', + 'ModuleForm', 'ModuleBayForm', 'ModuleBayTemplateForm', 'ModuleTypeForm', @@ -651,6 +652,46 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): self.fields['position'].widget.choices = [(position, f'U{position}')] +class ModuleForm(CustomFieldModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + initial_params={ + 'modulebays': '$module_bay' + } + ) + module_bay = DynamicModelChoiceField( + queryset=ModuleBay.objects.all(), + query_params={ + 'device_id': '$device' + } + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + initial_params={ + 'device_types': '$device_type' + } + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Module + fields = [ + 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', 'comments', + ] + + class CableForm(TenancyForm, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index d50c64d33..7f660b192 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -56,6 +56,9 @@ class DCIMQuery(graphene.ObjectType): manufacturer = ObjectField(ManufacturerType) manufacturer_list = ObjectListField(ManufacturerType) + module = ObjectField(ModuleType) + module_list = ObjectListField(ModuleType) + module_bay = ObjectField(ModuleBayType) module_bay_list = ObjectListField(ModuleBayType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index c1a8822d8..51e196076 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -27,6 +27,7 @@ __all__ = ( 'InventoryItemType', 'LocationType', 'ManufacturerType', + 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', 'ModuleTypeType', @@ -257,6 +258,14 @@ class ManufacturerType(OrganizationalObjectType): filterset_class = filtersets.ManufacturerFilterSet +class ModuleType(ComponentObjectType): + + class Meta: + model = models.Module + fields = '__all__' + filterset_class = filtersets.ModuleFilterSet + + class ModuleBayType(ComponentObjectType): class Meta: diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py index b9cb7bcc5..c9a332846 100644 --- a/netbox/dcim/migrations/0145_modules.py +++ b/netbox/dcim/migrations/0145_modules.py @@ -95,36 +95,110 @@ class Migration(migrations.Migration): 'unique_together': {('manufacturer', 'model')}, }, ), + migrations.CreateModel( + name='ModuleBay', + 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)), + ('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)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('device', '_name'), + 'unique_together': {('device', 'name')}, + }, + ), + migrations.CreateModel( + name='Module', + 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)), + ('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)), + ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')), + ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')), + ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('module_bay',), + }, + ), + migrations.AddField( + model_name='consoleport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.module'), + ), migrations.AddField( model_name='consoleporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='consoleserverport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.module'), + ), migrations.AddField( model_name='consoleserverporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='frontport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.module'), + ), migrations.AddField( model_name='frontporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='interface', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.module'), + ), migrations.AddField( model_name='interfacetemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='poweroutlet', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.module'), + ), migrations.AddField( model_name='poweroutlettemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='powerport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.module'), + ), migrations.AddField( model_name='powerporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='rearport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.module'), + ), migrations.AddField( model_name='rearporttemplate', name='module_type', @@ -140,7 +214,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='frontporttemplate', - unique_together={('device_type', 'name'), ('module_type', 'name'), ('rear_port', 'rear_port_position')}, + unique_together={('device_type', 'name'), ('rear_port', 'rear_port_position'), ('module_type', 'name')}, ), migrations.AlterUniqueTogether( name='interfacetemplate', @@ -175,23 +249,4 @@ class Migration(migrations.Migration): 'unique_together': {('device_type', 'name')}, }, ), - migrations.CreateModel( - name='ModuleBay', - 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)), - ('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)), - ('label', models.CharField(blank=True, max_length=64)), - ('description', models.CharField(blank=True, max_length=200)), - ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'ordering': ('device', '_name'), - 'unique_together': {('device', 'name')}, - }, - ), ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index a030dc3a8..8d4b1dce6 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -27,6 +27,7 @@ __all__ = ( 'InventoryItem', 'Location', 'Manufacturer', + 'Module', 'ModuleBay', 'ModuleBayTemplate', 'ModuleType', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index d522d543a..a22118de0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -142,12 +142,12 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return ConsolePort( - device=device, name=self.name, label=self.label, - type=self.type + type=self.type, + **kwargs ) @@ -169,12 +169,12 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return ConsoleServerPort( - device=device, name=self.name, label=self.label, - type=self.type + type=self.type, + **kwargs ) @@ -208,14 +208,14 @@ class PowerPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return PowerPort( - device=device, name=self.name, label=self.label, type=self.type, maximum_draw=self.maximum_draw, - allocated_draw=self.allocated_draw + allocated_draw=self.allocated_draw, + **kwargs ) def clean(self): @@ -273,18 +273,18 @@ class PowerOutletTemplate(ModularComponentTemplateModel): f"Parent power port ({self.power_port}) must belong to the same module type" ) - def instantiate(self, device): + def instantiate(self, **kwargs): if self.power_port: - power_port = PowerPort.objects.get(device=device, name=self.power_port.name) + power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs) else: power_port = None return PowerOutlet( - device=device, name=self.name, label=self.label, type=self.type, power_port=power_port, - feed_leg=self.feed_leg + feed_leg=self.feed_leg, + **kwargs ) @@ -316,13 +316,13 @@ class InterfaceTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return Interface( - device=device, name=self.name, label=self.label, type=self.type, - mgmt_only=self.mgmt_only + mgmt_only=self.mgmt_only, + **kwargs ) @@ -381,19 +381,19 @@ class FrontPortTemplate(ModularComponentTemplateModel): except RearPortTemplate.DoesNotExist: pass - def instantiate(self, device): + def instantiate(self, **kwargs): if self.rear_port: - rear_port = RearPort.objects.get(device=device, name=self.rear_port.name) + rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs) else: rear_port = None return FrontPort( - device=device, name=self.name, label=self.label, type=self.type, color=self.color, rear_port=rear_port, - rear_port_position=self.rear_port_position + rear_port_position=self.rear_port_position, + **kwargs ) @@ -424,14 +424,14 @@ class RearPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return RearPort( - device=device, name=self.name, label=self.label, type=self.type, color=self.color, - positions=self.positions + positions=self.positions, + **kwargs ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 08e069239..fc80b29c9 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -87,6 +87,19 @@ class ComponentModel(PrimaryModel): return self.device +class ModularComponentModel(ComponentModel): + module = models.ForeignKey( + to='dcim.Module', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + class LinkTermination(models.Model): """ An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples @@ -234,7 +247,7 @@ class PathEndpoint(models.Model): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): +class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -262,7 +275,7 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): +class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -294,7 +307,7 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPort(ComponentModel, LinkTermination, PathEndpoint): +class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -387,7 +400,7 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): +class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -502,7 +515,7 @@ class BaseInterface(models.Model): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): +class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -765,7 +778,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FrontPort(ComponentModel, LinkTermination): +class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. """ @@ -819,7 +832,7 @@ class FrontPort(ComponentModel, LinkTermination): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RearPort(ComponentModel, LinkTermination): +class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ab06b7dc5..8d0a7ae19 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -26,6 +26,7 @@ __all__ = ( 'DeviceRole', 'DeviceType', 'Manufacturer', + 'Module', 'ModuleType', 'Platform', 'VirtualChassis', @@ -906,31 +907,31 @@ class Device(PrimaryModel, ConfigContextModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()] ) ConsoleServerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()] ) PowerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.powerporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()] ) PowerOutlet.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()] + [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()] ) Interface.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.interfacetemplates.all()] + [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()] ) RearPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.rearporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()] ) FrontPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.frontporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()] ) ModuleBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.modulebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()] ) DeviceBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] ) # Update Site and Rack assignment for any child Devices @@ -1008,6 +1009,85 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Module(PrimaryModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', + help_text='A unique tag used to identify this device' + ) + comments = models.TextField( + blank=True + ) + + clone_fields = ('device', 'module_type') + + class Meta: + ordering = ('module_bay',) + + def __str__(self): + return str(self.module_type) + + def get_absolute_url(self): + return reverse('dcim:module', args=[self.pk]) + + def save(self, *args, **kwargs): + is_new = not bool(self.pk) + + super().save(*args, **kwargs) + + # If this is a new Module, instantiate all its related components per the ModuleType definition + if is_new: + ConsolePort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] + ) + ConsoleServerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()] + ) + PowerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] + ) + PowerOutlet.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] + ) + Interface.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] + ) + RearPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] + ) + FrontPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] + ) + + # # Virtual chassis # diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 688b8771c..993ae0518 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -6,7 +6,7 @@ from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * -from .moduletypes import * +from .modules import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index df1d79aa4..f8616b642 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -725,26 +725,31 @@ class ModuleBayTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + installed_module = tables.Column( + linkify=True, + verbose_name='Installed module' + ) tags = TagColumn( url_name='dcim:modulebay_list' ) class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'device', 'label', 'description', 'tags') - default_columns = ('pk', 'name', 'device', 'label', 'description') + fields = ('pk', 'id', 'name', 'device', 'label', 'installed_module', 'description', 'tags') + default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') class DeviceModuleBayTable(ModuleBayTable): actions = ButtonsColumn( - model=ModuleBay, - buttons=('edit', 'delete') + model=DeviceBay, + buttons=('edit', 'delete'), + prepend_template=MODULEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'label', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'label', 'description', 'actions') + fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions') class InventoryItemTable(DeviceComponentTable): diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py new file mode 100644 index 000000000..6d620433a --- /dev/null +++ b/netbox/dcim/tables/modules.py @@ -0,0 +1,61 @@ +import django_tables2 as tables + +from dcim.models import Module, ModuleType +from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn + +__all__ = ( + 'ModuleTable', + 'ModuleTypeTable', +) + + +class ModuleTypeTable(BaseTable): + pk = ToggleColumn() + model = tables.Column( + linkify=True, + verbose_name='Module Type' + ) + instance_count = LinkedCountColumn( + viewname='dcim:module_list', + url_params={'module_type_id': 'pk'}, + verbose_name='Instances' + ) + comments = MarkdownColumn() + tags = TagColumn( + url_name='dcim:moduletype_list' + ) + + class Meta(BaseTable.Meta): + model = ModuleType + fields = ( + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'model', 'manufacturer', 'part_number', + ) + + +class ModuleTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + module_bay = tables.Column( + linkify=True + ) + module_type = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + tags = TagColumn( + url_name='dcim:module_list' + ) + + class Meta(BaseTable.Meta): + model = Module + fields = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', + ) diff --git a/netbox/dcim/tables/moduletypes.py b/netbox/dcim/tables/moduletypes.py deleted file mode 100644 index 23bf2e965..000000000 --- a/netbox/dcim/tables/moduletypes.py +++ /dev/null @@ -1,34 +0,0 @@ -import django_tables2 as tables - -from dcim.models import ModuleType -from utilities.tables import BaseTable, MarkdownColumn, TagColumn, ToggleColumn - -__all__ = ( - 'ModuleTypeTable', -) - - -class ModuleTypeTable(BaseTable): - pk = ToggleColumn() - model = tables.Column( - linkify=True, - verbose_name='Device Type' - ) - # instance_count = LinkedCountColumn( - # viewname='dcim:module_list', - # url_params={'module_type_id': 'pk'}, - # verbose_name='Instances' - # ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='dcim:moduletype_list' - ) - - class Meta(BaseTable.Meta): - model = ModuleType - fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', - ) - default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', - ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index ccca32be8..6b44c4b3f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -321,3 +321,17 @@ DEVICEBAY_BUTTONS = """ {% endif %} {% endif %} """ + +MODULEBAY_BUTTONS = """ +{% if perms.dcim.add_module %} + {% if record.installed_module %} + + + + {% else %} + + + + {% endif %} +{% endif %} +""" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 597b6d50b..3b6410c8c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,7 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.models import WirelessLAN @@ -1105,6 +1105,67 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) +class ModuleTest(APIViewTestCases.APIViewTestCase): + model = Module + brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url'] + bulk_update_data = { + 'serial': '1234ABCD', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device = create_test_device('Test Device 1') + + module_types = ( + ModuleType(manufacturer=manufacturer, model='Module Type 1'), + ModuleType(manufacturer=manufacturer, model='Module Type 2'), + ModuleType(manufacturer=manufacturer, model='Module Type 3'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=device, name='Module Bay 1'), + ModuleBay(device=device, name='Module Bay 2'), + ModuleBay(device=device, name='Module Bay 3'), + ModuleBay(device=device, name='Module Bay 4'), + ModuleBay(device=device, name='Module Bay 5'), + ModuleBay(device=device, name='Module Bay 6'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=device, module_bay=module_bays[0], module_type=module_types[0]), + Module(device=device, module_bay=module_bays[1], module_type=module_types[1]), + Module(device=device, module_bay=module_bays[2], module_type=module_types[2]), + ) + Module.objects.bulk_create(modules) + + cls.create_data = [ + { + 'device': device.pk, + 'module_bay': module_bays[3].pk, + 'module_type': module_types[0].pk, + 'serial': 'ABC123', + 'asset_tag': 'Foo1', + }, + { + 'device': device.pk, + 'module_bay': module_bays[4].pk, + 'module_type': module_types[1].pk, + 'serial': 'DEF456', + 'asset_tag': 'Foo2', + }, + { + 'device': device.pk, + 'module_bay': module_bays[5].pk, + 'module_type': module_types[2].pk, + 'serial': 'GHI789', + 'asset_tag': 'Foo3', + }, + ] + + class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 4fd1286da..8f04fb4d9 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -7,7 +7,7 @@ from dcim.models import * from ipam.models import ASN, IPAddress, RIR from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices @@ -1648,6 +1648,79 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = Module.objects.all() + filterset = ModuleFilterSet + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + devices = ( + create_test_device('Test Device 1'), + create_test_device('Test Device 2'), + create_test_device('Test Device 3'), + ) + + module_types = ( + ModuleType(manufacturer=manufacturers[0], model='Module Type 1'), + ModuleType(manufacturer=manufacturers[1], model='Module Type 2'), + ModuleType(manufacturer=manufacturers[2], model='Module Type 3'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[0], name='Module Bay 2'), + ModuleBay(device=devices[0], name='Module Bay 3'), + ModuleBay(device=devices[1], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[1], name='Module Bay 3'), + ModuleBay(device=devices[2], name='Module Bay 1'), + ModuleBay(device=devices[2], name='Module Bay 2'), + ModuleBay(device=devices[2], name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'), + Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'), + Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'), + Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'), + Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'), + Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'), + Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'), + Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'), + Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'), + ) + Module.objects.bulk_create(modules) + + def test_manufacturer(self): + manufacturers = Manufacturer.objects.all()[:2] + params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_device(self): + device_types = Device.objects.all()[:2] + params = {'device_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_serial(self): + params = {'asset_tag': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_asset_tag(self): + params = {'asset_tag': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePort.objects.all() filterset = ConsolePortFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6094fe739..12216a8ac 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1697,6 +1697,75 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertHttpStatus(self.client.get(url), 200) +class ModuleTestCase( + # Module does not support bulk renaming (no name field) or + # bulk creation (need to specify module bays) + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = Module + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + devices = ( + create_test_device('Device 1'), + create_test_device('Device 2'), + ) + + module_types = ( + ModuleType(manufacturer=manufacturer, model='Module Type 1'), + ModuleType(manufacturer=manufacturer, model='Module Type 2'), + ModuleType(manufacturer=manufacturer, model='Module Type 3'), + ModuleType(manufacturer=manufacturer, model='Module Type 4'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[0], name='Module Bay 2'), + ModuleBay(device=devices[0], name='Module Bay 3'), + ModuleBay(device=devices[1], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[1], name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]), + Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1]), + Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2]), + ) + Module.objects.bulk_create(modules) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': devices[1].pk, + 'module_bay': module_bays[3].pk, + 'module_type': module_types[0].pk, + 'serial': 'A', + 'tags': [t.pk for t in tags], + } + + cls.bulk_edit_data = { + 'module_type': module_types[3].pk, + } + + cls.csv_data = ( + "device,module_bay,module_type,serial,asset_tag", + "Device 2,Module Bay 1,Module Type 1,A,A", + "Device 2,Module Bay 2,Module Type 2,B,B", + "Device 2,Module Bay 3,Module Type 3,C,C", + ) + + class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e1c1e200f..8ec30c0cc 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -254,12 +254,24 @@ urlpatterns = [ 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/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path('devices//journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}), + 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'), + # Modules + path('modules/', views.ModuleListView.as_view(), name='module_list'), + path('modules/add/', views.ModuleEditView.as_view(), name='module_add'), + path('modules/import/', views.ModuleBulkImportView.as_view(), name='module_import'), + path('modules/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'), + path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'), + 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}), + # Console ports path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f673e64d5..3bc264554 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit -from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView +from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic @@ -30,7 +30,7 @@ from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed, + InventoryItem, Manufacturer, Module, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis, ) @@ -1629,14 +1629,6 @@ class DeviceConfigContextView(ObjectConfigContextView): base_template = 'dcim/device/base.html' -class DeviceChangeLogView(ObjectChangeLogView): - base_template = 'dcim/device/base.html' - - -class DeviceJournalView(ObjectJournalView): - base_template = 'dcim/device/base.html' - - class DeviceEditView(generic.ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm @@ -1685,6 +1677,49 @@ class DeviceBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTable +# +# Devices +# + +class ModuleListView(generic.ObjectListView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + filterset_form = forms.ModuleFilterForm + table = tables.ModuleTable + + +class ModuleView(generic.ObjectView): + queryset = Module.objects.all() + + +class ModuleEditView(generic.ObjectEditView): + queryset = Module.objects.all() + model_form = forms.ModuleForm + + +class ModuleDeleteView(generic.ObjectDeleteView): + queryset = Module.objects.all() + + +class ModuleBulkImportView(generic.BulkImportView): + queryset = Module.objects.all() + model_form = forms.ModuleCSVForm + table = tables.ModuleTable + + +class ModuleBulkEditView(generic.BulkEditView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + table = tables.ModuleTable + form = forms.ModuleBulkEditForm + + +class ModuleBulkDeleteView(generic.BulkDeleteView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + table = tables.ModuleTable + + # # Console ports # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a2bec4710..52359dcc6 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -139,6 +139,7 @@ DEVICES_MENU = Menu( label='Devices', items=( get_model_item('dcim', 'device', 'Devices'), + get_model_item('dcim', 'module', 'Modules'), get_model_item('dcim', 'devicerole', 'Device Roles'), get_model_item('dcim', 'platform', 'Platforms'), get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'), diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 80ccb69a2..d9ff0657c 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -102,6 +102,22 @@ + {% with devicebay_count=object.devicebays.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} + + {% with modulebay_count=object.modulebays.count %} + {% if modulebay_count %} + + {% endif %} + {% endwith %} + {% with interface_count=object.interfaces_count %} {% if interface_count %} - {% endif %} - {% endwith %} - - {% with devicebay_count=object.devicebays.count %} - {% if devicebay_count %} - - {% endif %} - {% endwith %} - {% with inventoryitem_count=object.inventoryitems.count %} {% if inventoryitem_count %} + {% with devicebay_count=object.devicebaytemplates.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} + + {% with modulebay_count=object.modulebaytemplates.count %} + {% if modulebay_count %} + + {% endif %} + {% endwith %} + {% with interface_count=object.interfacetemplates.count %} {% if interface_count %} {% endif %} {% endwith %} - - {% with modulebay_count=object.modulebaytemplates.count %} - {% if modulebay_count %} - - {% endif %} - {% endwith %} - - {% with devicebay_count=object.devicebaytemplates.count %} - {% if devicebay_count %} - - {% endif %} - {% endwith %} {% endblock %} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html new file mode 100644 index 000000000..8410b9556 --- /dev/null +++ b/netbox/templates/dcim/module.html @@ -0,0 +1,154 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load tz %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    Module
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ object.device }} +
    Device Type + {{ object.device.device_type }} +
    Module Type + {{ object.module_type }} +
    Serial Number{{ object.serial|placeholder }}
    Asset Tag{{ object.asset_tag|placeholder }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
    +
    +
    +
    Components
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Interfaces + {% with component_count=object.interfaces.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Console Ports + {% with component_count=object.consoleports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Console Server Ports + {% with component_count=object.consoleserverports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Power Ports + {% with component_count=object.powerports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Power Outlets + {% with component_count=object.poweroutlets.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Front Ports + {% with component_count=object.frontports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Rear Ports + {% with component_count=object.rearports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} From e0d7511eaa963eb0298ece41d1c431e092b20d65 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 16:27:03 -0500 Subject: [PATCH 020/271] Misc cleanup --- netbox/dcim/views.py | 2 +- netbox/templates/dcim/modulebay.html | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3bc264554..7ce67ab97 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2200,7 +2200,7 @@ class RearPortBulkDeleteView(generic.BulkDeleteView): # class ModuleBayListView(generic.ObjectListView): - queryset = ModuleBay.objects.all() + queryset = ModuleBay.objects.select_related('installed_module__module_type') filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable diff --git a/netbox/templates/dcim/modulebay.html b/netbox/templates/dcim/modulebay.html index 3dfcc68b9..0f903483a 100644 --- a/netbox/templates/dcim/modulebay.html +++ b/netbox/templates/dcim/modulebay.html @@ -12,7 +12,7 @@ Device - {{ object.device }} + {{ object.device }} @@ -30,26 +30,28 @@ - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    + {% include 'inc/panels/custom_fields.html' %}
    Installed Module
    - {% if object.module %} - {% with module=object.module %} + {% if object.installed_module %} + {% with module=object.installed_module %} - + - +
    ModuleManufacturer - {{ module }} + {{ module.module_type.manufacturer }}
    Module Type{{ module.module_type }} + {{ module.module_type }} +
    {% endwith %} From 7dc4e00b4d0f27e089f4b24ef780b7501510ccf3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 20:15:49 -0500 Subject: [PATCH 021/271] Add module, module_bay columns to device component tables --- netbox/dcim/tables/devices.py | 92 ++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f8616b642..08f229d33 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -257,6 +257,19 @@ class DeviceComponentTable(BaseTable): order_by = ('device', 'name') +class ModularDeviceComponentTable(DeviceComponentTable): + module_bay = tables.Column( + accessor=Accessor('module__module_bay'), + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + module = tables.Column( + linkify=True + ) + + class CableTerminationTable(BaseTable): cable = tables.Column( linkify=True @@ -284,7 +297,7 @@ class PathEndpointTable(CableTerminationTable): ) -class ConsolePortTable(DeviceComponentTable, PathEndpointTable): +class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleports', @@ -298,8 +311,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -319,8 +332,8 @@ class DeviceConsolePortTable(ConsolePortTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions' + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -328,7 +341,7 @@ class DeviceConsolePortTable(ConsolePortTable): } -class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): +class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleserverports', @@ -342,8 +355,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -364,8 +377,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -373,7 +386,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): } -class PowerPortTable(DeviceComponentTable, PathEndpointTable): +class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_powerports', @@ -387,8 +400,8 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', - 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', + 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -409,8 +422,8 @@ class DevicePowerPortTable(PowerPortTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', @@ -421,7 +434,7 @@ class DevicePowerPortTable(PowerPortTable): } -class PowerOutletTable(DeviceComponentTable, PathEndpointTable): +class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_poweroutlets', @@ -438,8 +451,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', + 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -459,8 +472,8 @@ class DevicePowerOutletTable(PowerOutletTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', @@ -491,7 +504,7 @@ class BaseInterfaceTable(BaseTable): ) -class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): +class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_interfaces', @@ -514,10 +527,10 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', - 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', - 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', + 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', + 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -549,10 +562,11 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', - 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', - 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', + 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( @@ -566,7 +580,7 @@ class DeviceInterfaceTable(InterfaceTable): } -class FrontPortTable(DeviceComponentTable, CableTerminationTable): +class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_frontports', @@ -587,8 +601,8 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -611,8 +625,8 @@ class DeviceFrontPortTable(FrontPortTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', @@ -623,7 +637,7 @@ class DeviceFrontPortTable(FrontPortTable): } -class RearPortTable(DeviceComponentTable, CableTerminationTable): +class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_rearports', @@ -638,8 +652,8 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -660,8 +674,8 @@ class DeviceRearPortTable(RearPortTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', From ed6a160372fbfd46602accd6b57278625656640d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 20:31:15 -0500 Subject: [PATCH 022/271] Add modules to device component serializers --- netbox/dcim/api/nested_serializers.py | 24 ++++++++- netbox/dcim/api/serializers.py | 70 +++++++++++++++++++-------- netbox/dcim/api/views.py | 26 +++++++--- 3 files changed, 90 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 6ed7c63c6..9440e5d4b 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -4,6 +4,7 @@ from dcim import models from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ + 'ComponentNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -261,11 +262,30 @@ class NestedDeviceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class ModuleNestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + + class Meta: + model = models.ModuleBay + fields = ['id', 'url', 'display', 'name'] + + +class ComponentNestedModuleSerializer(WritableNestedSerializer): + """ + Used by device component serializers. + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + module_bay = ModuleNestedModuleBaySerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay'] + + class NestedModuleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer(read_only=True) - # TODO: Solve circular dependency - # module_bay = NestedModuleBaySerializer(read_only=True) + module_bay = ModuleNestedModuleBaySerializer(read_only=True) module_type = NestedModuleTypeSerializer(read_only=True) class Meta: diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b58355f32..c81b26d1f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -558,6 +558,10 @@ class DeviceNAPALMSerializer(serializers.Serializer): class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -573,8 +577,8 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali class Meta: model = ConsoleServerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -582,6 +586,10 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -597,8 +605,8 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = ConsolePort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -606,6 +614,10 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, @@ -627,15 +639,20 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, @@ -646,15 +663,20 @@ class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = PowerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=InterfaceTypeChoices) parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) @@ -683,12 +705,12 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', - 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', - 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - 'count_fhrp_groups', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', + 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', + 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): @@ -708,13 +730,17 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) cable = NestedCableSerializer(read_only=True) class Meta: model = RearPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -734,6 +760,10 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() cable = NestedCableSerializer(read_only=True) @@ -741,9 +771,9 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): class Meta: model = FrontPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', + 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 378e697c8..9d1be93d5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -539,7 +539,9 @@ class ModuleViewSet(CustomFieldModelViewSet): # class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') + queryset = ConsolePort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet brief_prefetch_fields = ['device'] @@ -547,7 +549,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet @@ -555,14 +557,18 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') + queryset = PowerPort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet brief_prefetch_fields = ['device'] class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') + queryset = PowerOutlet.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet brief_prefetch_fields = ['device'] @@ -570,8 +576,8 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', + 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -579,14 +585,18 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet): class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') + queryset = FrontPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags' + ) serializer_class = serializers.FrontPortSerializer filterset_class = filtersets.FrontPortFilterSet brief_prefetch_fields = ['device'] class RearPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') + queryset = RearPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags' + ) serializer_class = serializers.RearPortSerializer filterset_class = filtersets.RearPortFilterSet brief_prefetch_fields = ['device'] From eaa1165611999bb5a44980622f9335c0da3cda93 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Dec 2021 09:51:55 -0500 Subject: [PATCH 023/271] Add position field for module bays --- netbox/dcim/api/serializers.py | 9 ++++-- netbox/dcim/forms/bulk_edit.py | 4 +-- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 5 +++- netbox/dcim/forms/models.py | 4 +-- netbox/dcim/forms/object_create.py | 24 +++++++++------- netbox/dcim/forms/object_import.py | 2 +- netbox/dcim/migrations/0145_modules.py | 2 ++ .../dcim/models/device_component_templates.py | 28 +++++++++++++------ netbox/dcim/models/device_components.py | 6 ++++ netbox/dcim/tables/devices.py | 2 +- netbox/dcim/tables/devicetypes.py | 2 +- netbox/templates/dcim/modulebay.html | 4 +++ netbox/templates/dcim/moduletype/base.html | 2 +- 14 files changed, 65 insertions(+), 31 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c81b26d1f..cf6c89333 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -432,7 +432,10 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = ModuleBayTemplate - fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', + 'last_updated', + ] class DeviceBayTemplateSerializer(ValidatedModelSerializer): @@ -785,8 +788,8 @@ class ModuleBaySerializer(PrimaryModelSerializer): class Meta: model = ModuleBay fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', ] diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 378620180..d40ac6fca 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -886,7 +886,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'position', 'description') class DeviceBayTemplateBulkEditForm(BulkEditForm): @@ -1153,7 +1153,7 @@ class ModuleBayBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ['label', 'position', 'description'] class DeviceBayBulkEditForm( diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 8f5ba25b7..23d589abf 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -717,7 +717,7 @@ class ModuleBayCSVForm(CustomFieldModelCSVForm): class Meta: model = ModuleBay - fields = ('device', 'name', 'label', 'description') + fields = ('device', 'name', 'label', 'position', 'description') class DeviceBayCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 29c09c7f7..819cb91cc 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1073,10 +1073,13 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay field_groups = [ ['q', 'tag'], - ['name', 'label'], + ['name', 'label', 'position'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] tag = TagFilterField(model) + position = forms.CharField( + required=False + ) class DeviceBayFilterForm(DeviceComponentFilterForm): diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 672c54c68..2d32093c4 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1059,7 +1059,7 @@ class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'name', 'label', 'description', + 'device_type', 'name', 'label', 'position', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1313,7 +1313,7 @@ class ModuleBayForm(CustomFieldModelForm): class Meta: model = ModuleBay fields = [ - 'device', 'name', 'label', 'description', 'tags', + 'device', 'name', 'label', 'position', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 1619f5424..681a17a5a 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -163,6 +163,12 @@ class ComponentTemplateCreateForm(ComponentForm): 'manufacturer_id': '$manufacturer' } ) + description = forms.CharField( + required=False + ) + + +class ModularComponentTemplateCreateForm(ComponentTemplateCreateForm): module_type = DynamicModelChoiceField( queryset=ModuleType.objects.all(), required=False, @@ -170,12 +176,9 @@ class ComponentTemplateCreateForm(ComponentForm): 'manufacturer_id': '$manufacturer' } ) - description = forms.CharField( - required=False - ) -class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): +class ConsolePortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect() @@ -185,7 +188,7 @@ class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): ) -class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): +class ConsoleServerPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect() @@ -195,7 +198,7 @@ class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): ) -class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): +class PowerPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False @@ -216,7 +219,7 @@ class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): ) -class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): +class PowerOutletTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False @@ -240,7 +243,7 @@ class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): ) -class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): +class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect() @@ -255,7 +258,7 @@ class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): ) -class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): +class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect() @@ -320,7 +323,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): } -class RearPortTemplateCreateForm(ComponentTemplateCreateForm): +class RearPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect(), @@ -341,6 +344,7 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm): class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): + # TODO: Support patterned position assignment field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index cc0c7dc41..36c6ae8bc 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -155,7 +155,7 @@ class ModuleBayTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'name', 'label', 'description', + 'device_type', 'name', 'label', 'position', 'description', ] diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py index c9a332846..1f99c7c04 100644 --- a/netbox/dcim/migrations/0145_modules.py +++ b/netbox/dcim/migrations/0145_modules.py @@ -105,6 +105,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), ('description', models.CharField(blank=True, max_length=200)), ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), @@ -241,6 +242,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=64)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), ('description', models.CharField(blank=True, max_length=200)), ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebaytemplates', to='dcim.devicetype')), ], diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index a22118de0..71fed25c5 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -123,6 +123,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel): "A component template must be associated with either a device type or a module type." ) + def resolve_name(self, module): + if module: + return self.name.replace('{module}', module.module_bay.position) + return self.name + @extras_features('webhooks') class ConsolePortTemplate(ModularComponentTemplateModel): @@ -144,7 +149,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return ConsolePort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, **kwargs @@ -171,7 +176,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return ConsoleServerPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, **kwargs @@ -210,7 +215,7 @@ class PowerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return PowerPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, maximum_draw=self.maximum_draw, @@ -279,7 +284,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): else: power_port = None return PowerOutlet( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, power_port=power_port, @@ -318,7 +323,7 @@ class InterfaceTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return Interface( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, mgmt_only=self.mgmt_only, @@ -387,7 +392,7 @@ class FrontPortTemplate(ModularComponentTemplateModel): else: rear_port = None return FrontPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, color=self.color, @@ -426,7 +431,7 @@ class RearPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return RearPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, color=self.color, @@ -440,6 +445,12 @@ class ModuleBayTemplate(ComponentTemplateModel): """ A template for a ModuleBay to be created for a new parent Device. """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') @@ -448,7 +459,8 @@ class ModuleBayTemplate(ComponentTemplateModel): return ModuleBay( device=device, name=self.name, - label=self.label + label=self.label, + position=self.position ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index fc80b29c9..ccfe538d7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -880,6 +880,12 @@ class ModuleBay(ComponentModel): """ An empty space within a Device which can house a child device """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + clone_fields = ['device'] class Meta: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 08f229d33..7805e60c1 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -749,7 +749,7 @@ class ModuleBayTable(DeviceComponentTable): class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'device', 'label', 'installed_module', 'description', 'tags') + fields = ('pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'description', 'tags') default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 6fc038542..38a33c652 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -217,7 +217,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = ModuleBayTemplate - fields = ('pk', 'name', 'label', 'description', 'actions') + fields = ('pk', 'name', 'label', 'position', 'description', 'actions') empty_text = "None" diff --git a/netbox/templates/dcim/modulebay.html b/netbox/templates/dcim/modulebay.html index 0f903483a..9f04ba225 100644 --- a/netbox/templates/dcim/modulebay.html +++ b/netbox/templates/dcim/modulebay.html @@ -23,6 +23,10 @@ Label {{ object.label|placeholder }} + + Position + {{ object.position|placeholder }} + Description {{ object.description|placeholder }} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html index 563e23a7b..2d0bca7d8 100644 --- a/netbox/templates/dcim/moduletype/base.html +++ b/netbox/templates/dcim/moduletype/base.html @@ -45,7 +45,7 @@ {% block tab_items %} From e35aa4bd1e479690a3ca23ec40d7ee897eaeee8a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Dec 2021 10:31:18 -0500 Subject: [PATCH 024/271] Add documentation for modules --- docs/core-functionality/device-types.md | 1 + docs/core-functionality/devices.md | 1 + docs/core-functionality/modules.md | 4 ++++ docs/models/dcim/devicebay.md | 2 +- docs/models/dcim/devicebaytemplate.md | 2 +- docs/models/dcim/module.md | 5 +++++ docs/models/dcim/modulebay.md | 3 +++ docs/models/dcim/modulebaytemplate.md | 3 +++ docs/models/dcim/moduletype.md | 23 +++++++++++++++++++++++ mkdocs.yml | 1 + 10 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 docs/core-functionality/modules.md create mode 100644 docs/models/dcim/module.md create mode 100644 docs/models/dcim/modulebay.md create mode 100644 docs/models/dcim/modulebaytemplate.md create mode 100644 docs/models/dcim/moduletype.md diff --git a/docs/core-functionality/device-types.md b/docs/core-functionality/device-types.md index 037d3cfd0..ec5cbacdb 100644 --- a/docs/core-functionality/device-types.md +++ b/docs/core-functionality/device-types.md @@ -37,4 +37,5 @@ Once component templates have been created, every new device that you create as {!models/dcim/interfacetemplate.md!} {!models/dcim/frontporttemplate.md!} {!models/dcim/rearporttemplate.md!} +{!models/dcim/modulebaytemplate.md!} {!models/dcim/devicebaytemplate.md!} diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 982ee3071..35c978210 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -17,6 +17,7 @@ Device components represent discrete objects within a device which are used to t {!models/dcim/interface.md!} {!models/dcim/frontport.md!} {!models/dcim/rearport.md!} +{!models/dcim/modulebay.md!} {!models/dcim/devicebay.md!} {!models/dcim/inventoryitem.md!} diff --git a/docs/core-functionality/modules.md b/docs/core-functionality/modules.md new file mode 100644 index 000000000..4d32fe18c --- /dev/null +++ b/docs/core-functionality/modules.md @@ -0,0 +1,4 @@ +# Modules + +{!models/dcim/moduletype.md!} +{!models/dcim/module.md!} diff --git a/docs/models/dcim/devicebay.md b/docs/models/dcim/devicebay.md index 2aea14a7a..e79c426dc 100644 --- a/docs/models/dcim/devicebay.md +++ b/docs/models/dcim/devicebay.md @@ -5,4 +5,4 @@ Device bays represent a space or slot within a parent device in which a child de Child devices are first-class Devices in their own right: That is, they are fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and components. LAG interfaces may not group interfaces belonging to different child devices. !!! note - Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, these should be modeled as modules installed within module bays. diff --git a/docs/models/dcim/devicebaytemplate.md b/docs/models/dcim/devicebaytemplate.md index ebf7bd63c..a4c50067a 100644 --- a/docs/models/dcim/devicebaytemplate.md +++ b/docs/models/dcim/devicebaytemplate.md @@ -1,3 +1,3 @@ ## Device Bay Templates -A template for a device bay that will be created on all instantiations of the parent device type. +A template for a device bay that will be created on all instantiations of the parent device type. Device bays hold child devices, such as blade servers. diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md new file mode 100644 index 000000000..bc9753ecc --- /dev/null +++ b/docs/models/dcim/module.md @@ -0,0 +1,5 @@ +# Modules + +A module is a field-replaceable hardware component installed within a device which houses its own child components. The most common example is a chassis-based router or switch. + +Similar to devices, modules are instantiated from module types, and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a module bay on a device, and each module bay may have only one module installed in it. A module may optionally be assigned a serial number and asset tag. diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md new file mode 100644 index 000000000..6c6f94598 --- /dev/null +++ b/docs/models/dcim/modulebay.md @@ -0,0 +1,3 @@ +## Module Bays + +Module bays represent a space or slot within a device in which a field-replaceable module may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device. diff --git a/docs/models/dcim/modulebaytemplate.md b/docs/models/dcim/modulebaytemplate.md new file mode 100644 index 000000000..463789305 --- /dev/null +++ b/docs/models/dcim/modulebaytemplate.md @@ -0,0 +1,3 @@ +## Module Bay Templates + +A template for a module bay that will be created on all instantiations of the parent device type. Module bays hold installed modules that do not have an independent management plane, such as line cards. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md new file mode 100644 index 000000000..c1c8c5079 --- /dev/null +++ b/docs/models/dcim/moduletype.md @@ -0,0 +1,23 @@ +# Module Types + +A module type represent a specific make and model of hardware component which is installable within a device and has its own child components. For example, consider a chassis-based switch or router with a number of field-replaceable line cards. Each line card has its own model number and includes a certain set of components such as interfaces. Each module type may have a manufacturer, model number, and part number assigned to it. + +Similar to device types, each module type can have any of the following component templates associated with it: + +* Interfaces +* Console ports +* Console server ports +* Power ports +* Power Outlets +* Front pass-through ports +* Rear pass-through ports + +Note that device bays and module bays may _not_ be added to modules. + +## Automatic Component Renaming + +When adding component templates to a module type, the string `{module}` can be used to reference the `position` field of the module bay into which an instance of the module type is being installed. + +For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`. + +Automatic renaming is supported for all modular component types (those listed above). diff --git a/mkdocs.yml b/mkdocs.yml index 3fb838ffd..f89bdaea7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - Sites and Racks: 'core-functionality/sites-and-racks.md' - Devices and Cabling: 'core-functionality/devices.md' - Device Types: 'core-functionality/device-types.md' + - Modules: 'core-functionality/modules.md' - Virtualization: 'core-functionality/virtualization.md' - Service Mapping: 'core-functionality/services.md' - Circuits: 'core-functionality/circuits.md' From ae55ca7fd74782f1a7b0804d4e2d72b148769991 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Dec 2021 11:02:56 -0500 Subject: [PATCH 025/271] Changelog for #7844 --- docs/release-notes/version-3.2.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index cce60b581..ad8c4697a 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,12 @@ ### New Features +#### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) + +Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. + +Automatic renaming of module components is also supported. When a new module is created, any occurrence of the string `{module}` in a component name will be replaced with the position of the module bay into which the module is being installed. + #### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) Custom choices can be now added to most status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field: From 70f257b1ea64aae4d6e5868d2eb3d2e7c3889420 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 16:29:01 -0500 Subject: [PATCH 026/271] Introduce UserConfigForm for managing user preferences --- netbox/project-static/dist/netbox.js | Bin 374756 -> 374514 bytes netbox/project-static/dist/netbox.js.map | Bin 344228 -> 343986 bytes netbox/project-static/src/buttons/index.ts | 2 - .../project-static/src/buttons/preferences.ts | 30 -------- netbox/templates/users/preferences.html | 60 ++++------------ netbox/users/forms.py | 66 +++++++++++++++++- netbox/users/views.py | 30 ++++---- netbox/utilities/utils.py | 2 +- 8 files changed, 94 insertions(+), 96 deletions(-) delete mode 100644 netbox/project-static/src/buttons/preferences.ts diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 95fd99270f6418fc224d471cf1054aa65323fd0b..740fbe7e7888ea3f0e235cb7cd4f86b72477685f 100644 GIT binary patch delta 22234 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 delta 22377 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 R@~s*p>XgsFRpXuae*o+S3akJC diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 6fbe0874b1025b63008a8cc41168eb25890d4295..116aad5e6e4c2d8846a76397b4d64cf9ddd5647c 100644 GIT binary patch delta 83 zcmZ3|D7xvt$cB}6lmFUrOnzzSH+iL>N!E2NOqL$o#Z*}PWB?nh8+-r& delta 303 zcmdn=Ut~$6=!TVclmFX^3ni77l;r0X>lYNIrll68<|U_2o@JNXEN0&>X3q%3OhC-M zUCf@P;TB7Yi>1r<5;@j&EcM17UOJJEjuuWj?v9T6PCCwxt{E;Mwy%z-qoY5BmF(!8 z1Y(7Q2v40*N5^2Gth=R?jw6T<6a?{stZXN+A^wiedCnl=M4doKXD>_z!H&*`UCXPE%bktE!U)SvoOFtv9G$D2Ay#>VtnzhqjsThIt&<6~FVhKVgkz|V lr*nZ*f%EoRlB{!B7&E4u%dwhFpAgT&!x*t$MVYlv1^`4WSF`{C diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 251e0feaf..6a9001cd1 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -1,7 +1,6 @@ import { initConnectionToggle } from './connectionToggle'; import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; -import { initPreferenceUpdate } from './preferences'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; @@ -11,7 +10,6 @@ export function initButtons(): void { initConnectionToggle, initReslug, initSelectAll, - initPreferenceUpdate, initMoveButtons, ]) { func(); diff --git a/netbox/project-static/src/buttons/preferences.ts b/netbox/project-static/src/buttons/preferences.ts deleted file mode 100644 index 6e8b21c02..000000000 --- a/netbox/project-static/src/buttons/preferences.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { setColorMode } from '../colorMode'; -import { getElement } from '../util'; - -/** - * Perform actions in the UI based on the value of user profile updates. - * - * @param event Form Submit - */ -function handlePreferenceSave(event: Event): void { - // Create a FormData instance to access the form values. - const form = event.currentTarget as HTMLFormElement; - const formData = new FormData(form); - - // Update the UI color mode immediately when the user preference changes. - if (formData.get('ui.colormode') === 'dark') { - setColorMode('dark'); - } else if (formData.get('ui.colormode') === 'light') { - setColorMode('light'); - } -} - -/** - * Initialize handlers for user profile updates. - */ -export function initPreferenceUpdate(): void { - const form = getElement('preferences-update'); - if (form !== null) { - form.addEventListener('submit', handlePreferenceSave); - } -} diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index bbb92bde0..254b5b8ff 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -1,57 +1,27 @@ {% extends 'users/base.html' %} {% load helpers %} +{% load form_helpers %} {% block title %}User Preferences{% endblock %} {% block content %}
    {% csrf_token %} -
    -
    Color Mode
    -

    Set preferred UI color mode

    - {% with color_mode=preferences|get_key:'ui.colormode'%} -
    - - + + {% for group, fields in form.Meta.fieldsets %} +
    +
    +
    {{ group }}
    +
    + {% for name in fields %} + {% render_field form|getfield:name %} + {% endfor %}
    -
    - - -
    - {% endwith %} + {% endfor %} + +
    + Cancel +
    -
    -
    - -
    -
    - {% if preferences %} -
    -
    Other Preferences
    - - - - - - - - - - {% for key, value in preferences.items %} - - - - - - {% endfor %} - -
    PreferenceValue
    {{ key }}{{ value }}
    - -
    - {% else %} -

    No preferences found

    - {% endif %} {% endblock %} diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 8bd54cb66..7007ef958 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -2,7 +2,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from utilities.forms import BootstrapMixin, DateTimePicker -from .models import Token +from utilities.paginator import EnhancedPaginator +from utilities.utils import flatten_dict +from .models import Token, UserConfig class LoginForm(BootstrapMixin, AuthenticationForm): @@ -13,6 +15,68 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): pass +def get_page_lengths(): + return [ + (v, str(v)) for v in EnhancedPaginator.default_page_lengths + ] + + +class UserConfigForm(BootstrapMixin, forms.ModelForm): + pagination__per_page = forms.TypedChoiceField( + label='Page length', + coerce=lambda val: int(val), + choices=get_page_lengths, + required=False + ) + ui__colormode = forms.ChoiceField( + label='Color mode', + choices=( + ('light', 'Light'), + ('dark', 'Dark'), + ), + required=False + ) + extras__configcontext__format = forms.ChoiceField( + label='ConfigContext format', + choices=( + ('json', 'JSON'), + ('yaml', 'YAML'), + ), + required=False + ) + + class Meta: + model = UserConfig + fields = () + fieldsets = ( + ('User Interface', ( + 'pagination__per_page', + 'ui__colormode', + )), + ('Miscellaneous', ( + 'extras__configcontext__format', + )), + ) + + def __init__(self, *args, instance=None, **kwargs): + + # Get initial data from UserConfig instance + initial_data = flatten_dict(instance.data, separator='__') + kwargs['initial'] = initial_data + + super().__init__(*args, instance=instance, **kwargs) + + def save(self, *args, **kwargs): + + # Set UserConfig data + for field_name, value in self.cleaned_data.items(): + pref_name = field_name.replace('__', '.') + print(f'{pref_name}: {value}') + self.instance.set(pref_name, value, commit=False) + + return super().save(*args, **kwargs) + + class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( required=False, diff --git a/netbox/users/views.py b/netbox/users/views.py index ecf3295b5..cd3c34aa9 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -19,7 +19,7 @@ from extras.models import ObjectChange from extras.tables import ObjectChangeTable from netbox.config import get_config from utilities.forms import ConfirmationForm -from .forms import LoginForm, PasswordChangeForm, TokenForm +from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .models import Token @@ -137,32 +137,28 @@ class UserConfigView(LoginRequiredMixin, View): template_name = 'users/preferences.html' def get(self, request): + userconfig = request.user.config + form = UserConfigForm(instance=userconfig) return render(request, self.template_name, { - 'preferences': request.user.config.all(), + 'form': form, 'active_tab': 'preferences', }) def post(self, request): userconfig = request.user.config - data = userconfig.all() + form = UserConfigForm(request.POST, instance=userconfig) - # Delete selected preferences - if "_delete" in request.POST: - for key in request.POST.getlist('pk'): - if key in data: - userconfig.clear(key) - # Update specific values - elif "_update" in request.POST: - for key in request.POST: - if not key.startswith('_') and not key.startswith('csrf'): - for value in request.POST.getlist(key): - userconfig.set(key, value) + if form.is_valid(): + form.save() - userconfig.save() - messages.success(request, "Your preferences have been updated.") + messages.success(request, "Your preferences have been updated.") + return redirect('user:preferences') - return redirect('user:preferences') + return render(request, self.template_name, { + 'form': form, + 'active_tab': 'preferences', + }) class ChangePasswordView(LoginRequiredMixin, View): diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 3234135fb..3fc50ddc4 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -282,7 +282,7 @@ def flatten_dict(d, prefix='', separator='.'): for k, v in d.items(): key = separator.join([prefix, k]) if prefix else k if type(v) is dict: - ret.update(flatten_dict(v, prefix=key)) + ret.update(flatten_dict(v, prefix=key, separator=separator)) else: ret[key] = v return ret From 36d2422eefa25d2414b2141dca518e8111a55736 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 17:05:06 -0500 Subject: [PATCH 027/271] Introduce UserPreference to define user preferences --- netbox/users/forms.py | 60 ++++++++++++++++--------------------- netbox/users/preferences.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 netbox/users/preferences.py diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 7007ef958..c4e55c5bc 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -2,9 +2,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from utilities.forms import BootstrapMixin, DateTimePicker -from utilities.paginator import EnhancedPaginator from utilities.utils import flatten_dict from .models import Token, UserConfig +from .preferences import PREFERENCES class LoginForm(BootstrapMixin, AuthenticationForm): @@ -15,53 +15,45 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): pass -def get_page_lengths(): - return [ - (v, str(v)) for v in EnhancedPaginator.default_page_lengths - ] +class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported user preference + preference_fields = {} + for field_name, preference in PREFERENCES.items(): + field_kwargs = { + 'label': preference.label, + 'choices': preference.choices, + 'help_text': preference.description, + 'coerce': preference.coerce, + 'required': False, + } + preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) + attrs.update(preference_fields) + + return super().__new__(mcs, name, bases, attrs) -class UserConfigForm(BootstrapMixin, forms.ModelForm): - pagination__per_page = forms.TypedChoiceField( - label='Page length', - coerce=lambda val: int(val), - choices=get_page_lengths, - required=False - ) - ui__colormode = forms.ChoiceField( - label='Color mode', - choices=( - ('light', 'Light'), - ('dark', 'Dark'), - ), - required=False - ) - extras__configcontext__format = forms.ChoiceField( - label='ConfigContext format', - choices=( - ('json', 'JSON'), - ('yaml', 'YAML'), - ), - required=False - ) +class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass): class Meta: model = UserConfig fields = () fieldsets = ( ('User Interface', ( - 'pagination__per_page', - 'ui__colormode', + 'pagination.per_page', + 'ui.colormode', )), ('Miscellaneous', ( - 'extras__configcontext__format', + 'data_format', )), ) def __init__(self, *args, instance=None, **kwargs): # Get initial data from UserConfig instance - initial_data = flatten_dict(instance.data, separator='__') + initial_data = flatten_dict(instance.data) kwargs['initial'] = initial_data super().__init__(*args, instance=instance, **kwargs) @@ -69,9 +61,7 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm): def save(self, *args, **kwargs): # Set UserConfig data - for field_name, value in self.cleaned_data.items(): - pref_name = field_name.replace('__', '.') - print(f'{pref_name}: {value}') + for pref_name, value in self.cleaned_data.items(): self.instance.set(pref_name, value, commit=False) return super().save(*args, **kwargs) diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py new file mode 100644 index 000000000..18c3dbac0 --- /dev/null +++ b/netbox/users/preferences.py @@ -0,0 +1,46 @@ +from utilities.paginator import EnhancedPaginator + + +def get_page_lengths(): + return [ + (v, str(v)) for v in EnhancedPaginator.default_page_lengths + ] + + +class UserPreference: + + def __init__(self, label, choices, default=None, description='', coerce=lambda x: x): + self.label = label + self.choices = choices + self.default = default if default is not None else choices[0] + self.description = description + self.coerce = coerce + + +PREFERENCES = { + + # User interface + 'ui.colormode': UserPreference( + label='Color mode', + choices=( + ('light', 'Light'), + ('dark', 'Dark'), + ), + default='light', + ), + 'pagination.per_page': UserPreference( + label='Page length', + choices=get_page_lengths(), + coerce=lambda x: int(x) + ), + + # Miscellaneous + 'data_format': UserPreference( + label='Data format', + choices=( + ('json', 'JSON'), + ('yaml', 'YAML'), + ), + ), + +} From 2c01e178c7ea43fc04e0b811ffd540325670a681 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 19:59:33 -0500 Subject: [PATCH 028/271] Update config context display to reference data_format preference --- docs/development/user-preferences.md | 11 ++++++----- netbox/extras/views.py | 8 ++++---- netbox/users/forms.py | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index 0595bc358..a707eb6ad 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,8 +4,9 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -| ---- | ----------- | -| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table | +| Name | Description | +|-------------------------|-------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| ui.colormode | Light or dark mode in the user interface | diff --git a/netbox/extras/views.py b/netbox/extras/views.py index a2bc92f88..256709c6a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -296,9 +296,9 @@ class ConfigContextView(generic.ObjectView): if request.GET.get('format') in ['json', 'yaml']: format = request.GET.get('format') if request.user.is_authenticated: - request.user.config.set('extras.configcontext.format', format, commit=True) + request.user.config.set('data_format', format, commit=True) elif request.user.is_authenticated: - format = request.user.config.get('extras.configcontext.format', 'json') + format = request.user.config.get('data_format', 'json') else: format = 'json' @@ -341,9 +341,9 @@ class ObjectConfigContextView(generic.ObjectView): if request.GET.get('format') in ['json', 'yaml']: format = request.GET.get('format') if request.user.is_authenticated: - request.user.config.set('extras.configcontext.format', format, commit=True) + request.user.config.set('data_format', format, commit=True) elif request.user.is_authenticated: - format = request.user.config.get('extras.configcontext.format', 'json') + format = request.user.config.get('data_format', 'json') else: format = 'json' diff --git a/netbox/users/forms.py b/netbox/users/forms.py index c4e55c5bc..5a4b1c2ff 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm -from utilities.forms import BootstrapMixin, DateTimePicker +from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict from .models import Token, UserConfig from .preferences import PREFERENCES @@ -28,6 +28,7 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): 'help_text': preference.description, 'coerce': preference.coerce, 'required': False, + 'widget': StaticSelect, } preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) attrs.update(preference_fields) From 1eeac7f4f45f63bad3b371b8a72d8575aea2693d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Dec 2021 20:30:59 -0500 Subject: [PATCH 029/271] Introduce DEFAULT_USER_PREFERENCES dynamic config setting --- docs/configuration/dynamic-settings.md | 16 ++++++++++++++++ netbox/extras/admin.py | 3 +++ netbox/netbox/config/parameters.py | 9 +++++++++ netbox/users/forms.py | 5 ++++- netbox/users/models.py | 4 +++- netbox/users/preferences.py | 1 + 6 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index a222272c2..5649eb9be 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -66,6 +66,22 @@ CUSTOM_VALIDATORS = { --- +## DEFAULT_USER_PREFERENCES + +This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: + +```python +DEFAULT_USER_PREFERENCES = { + "pagination": { + "per_page": 100 + } +} +``` + +For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. + +--- + ## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index b6ee01db9..2c98d2a81 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -33,6 +33,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('NAPALM', { 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), }), + ('User Preferences', { + 'fields': ('DEFAULT_USER_PREFERENCES',), + }), ('Miscellaneous', { 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), }), diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index b4f16bf28..d3ebc7bff 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -131,6 +131,15 @@ PARAMS = ( field=forms.JSONField ), + # User preferences + ConfigParam( + name='DEFAULT_USER_PREFERENCES', + label='Default preferences', + default={}, + description="Default preferences for new users", + field=forms.JSONField + ), + # Miscellaneous ConfigParam( name='MAINTENANCE_MODE', diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 5a4b1c2ff..721c68e43 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.utils.html import mark_safe from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict @@ -22,10 +23,12 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): # Emulate a declared field for each supported user preference preference_fields = {} for field_name, preference in PREFERENCES.items(): + description = f'{preference.description}
    ' if preference.description else '' + help_text = f'{description}{field_name}' field_kwargs = { 'label': preference.label, 'choices': preference.choices, - 'help_text': preference.description, + 'help_text': mark_safe(help_text), 'coerce': preference.coerce, 'required': False, 'widget': StaticSelect, diff --git a/netbox/users/models.py b/netbox/users/models.py index 64b6432a7..7b768b57f 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -10,6 +10,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netbox.config import get_config from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict @@ -166,7 +167,8 @@ def create_userconfig(instance, created, **kwargs): Automatically create a new UserConfig when a new User is created. """ if created: - UserConfig(user=instance).save() + config = get_config() + UserConfig(user=instance, data=config.DEFAULT_USER_PREFERENCES).save() # diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py index 18c3dbac0..635393913 100644 --- a/netbox/users/preferences.py +++ b/netbox/users/preferences.py @@ -31,6 +31,7 @@ PREFERENCES = { 'pagination.per_page': UserPreference( label='Page length', choices=get_page_lengths(), + description='The number of objects to display per page', coerce=lambda x: int(x) ), From 1aafcf241fe537025f8a5860d468320c1d2202d5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 09:10:50 -0500 Subject: [PATCH 030/271] Enable plugins to define user preferences --- docs/plugins/development.md | 33 +++++++------ netbox/extras/plugins/__init__.py | 19 +++++++ .../extras/tests/dummy_plugin/preferences.py | 20 ++++++++ netbox/extras/tests/test_plugins.py | 9 ++++ netbox/netbox/preferences.py | 49 +++++++++++++++++++ netbox/templates/users/preferences.html | 4 ++ netbox/users/forms.py | 11 +---- netbox/users/preferences.py | 39 --------------- 8 files changed, 119 insertions(+), 65 deletions(-) create mode 100644 netbox/extras/tests/dummy_plugin/preferences.py create mode 100644 netbox/netbox/preferences.py diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 89436a321..d20f73cb6 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -99,22 +99,23 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i #### PluginConfig Attributes -| Name | Description | -| ---- | ----------- | -| `name` | Raw plugin name; same as the plugin's source directory | -| `verbose_name` | Human-friendly name for the plugin | -| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | -| `description` | Brief description of the plugin's purpose | -| `author` | Name of plugin's author | -| `author_email` | Author's public email address | -| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | -| `required_settings` | A list of any configuration parameters that **must** be defined by the user | -| `default_settings` | A dictionary of configuration parameters and their default values | -| `min_version` | Minimum version of NetBox with which the plugin is compatible | -| `max_version` | Maximum version of NetBox with which the plugin is compatible | -| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | -| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | -| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +| Name | Description | +| ---- |---------------------------------------------------------------------------------------------------------------| +| `name` | Raw plugin name; same as the plugin's source directory | +| `verbose_name` | Human-friendly name for the plugin | +| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `description` | Brief description of the plugin's purpose | +| `author` | Name of plugin's author | +| `author_email` | Author's public email address | +| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | +| `required_settings` | A list of any configuration parameters that **must** be defined by the user | +| `default_settings` | A dictionary of configuration parameters and their default values | +| `min_version` | Minimum version of NetBox with which the plugin is compatible | +| `max_version` | Maximum version of NetBox with which the plugin is compatible | +| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | +| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | +| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index f9a7856ea..5b02b5ab7 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -15,6 +15,7 @@ from extras.plugins.utils import import_object # Initialize plugin registry stores registry['plugin_template_extensions'] = collections.defaultdict(list) registry['plugin_menu_items'] = {} +registry['plugin_preferences'] = {} # @@ -54,6 +55,7 @@ class PluginConfig(AppConfig): # integrated components. template_extensions = 'template_content.template_extensions' menu_items = 'navigation.menu_items' + user_preferences = 'preferences.preferences' def ready(self): @@ -67,6 +69,12 @@ class PluginConfig(AppConfig): if menu_items is not None: register_menu_items(self.verbose_name, menu_items) + # Register user preferences + user_preferences = import_object(f"{self.__module__}.{self.user_preferences}") + if user_preferences is not None: + plugin_name = self.name.rsplit('.', 1)[1] + register_user_preferences(plugin_name, user_preferences) + @classmethod def validate(cls, user_config, netbox_version): @@ -242,3 +250,14 @@ def register_menu_items(section_name, class_list): raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") registry['plugin_menu_items'][section_name] = class_list + + +# +# User preferences +# + +def register_user_preferences(plugin_name, preferences): + """ + Register a list of user preferences defined by a plugin. + """ + registry['plugin_preferences'][plugin_name] = preferences diff --git a/netbox/extras/tests/dummy_plugin/preferences.py b/netbox/extras/tests/dummy_plugin/preferences.py new file mode 100644 index 000000000..f925ee6e0 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/preferences.py @@ -0,0 +1,20 @@ +from users.preferences import UserPreference + + +preferences = { + 'pref1': UserPreference( + label='First preference', + choices=( + ('foo', 'Foo'), + ('bar', 'Bar'), + ) + ), + 'pref2': UserPreference( + label='Second preference', + choices=( + ('a', 'A'), + ('b', 'B'), + ('c', 'C'), + ) + ), +} diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 2508ffb83..4bea9933e 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -74,6 +74,15 @@ class PluginTest(TestCase): self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site']) + def test_user_preferences(self): + """ + Check that plugin UserPreferences are registered. + """ + self.assertIn('dummy_plugin', registry['plugin_preferences']) + user_preferences = registry['plugin_preferences']['dummy_plugin'] + self.assertEqual(type(user_preferences), dict) + self.assertEqual(list(user_preferences.keys()), ['pref1', 'pref2']) + def test_middleware(self): """ Check that plugin middleware is registered. diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py new file mode 100644 index 000000000..4cad8cf24 --- /dev/null +++ b/netbox/netbox/preferences.py @@ -0,0 +1,49 @@ +from extras.registry import registry +from users.preferences import UserPreference +from utilities.paginator import EnhancedPaginator + + +def get_page_lengths(): + return [ + (v, str(v)) for v in EnhancedPaginator.default_page_lengths + ] + + +PREFERENCES = { + + # User interface + 'ui.colormode': UserPreference( + label='Color mode', + choices=( + ('light', 'Light'), + ('dark', 'Dark'), + ), + default='light', + ), + 'pagination.per_page': UserPreference( + label='Page length', + choices=get_page_lengths(), + description='The number of objects to display per page', + coerce=lambda x: int(x) + ), + + # Miscellaneous + 'data_format': UserPreference( + label='Data format', + choices=( + ('json', 'JSON'), + ('yaml', 'YAML'), + ), + ), + +} + +# Register plugin preferences +if registry['plugin_preferences']: + plugin_preferences = {} + + for plugin_name, preferences in registry['plugin_preferences'].items(): + for name, userpreference in preferences.items(): + PREFERENCES[f'plugins.{plugin_name}.{name}'] = userpreference + + PREFERENCES.update(plugin_preferences) diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 254b5b8ff..06a48b431 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -8,6 +8,7 @@
    {% csrf_token %} + {% comment %} {% for group, fields in form.Meta.fieldsets %}
    @@ -18,6 +19,9 @@ {% endfor %}
    {% endfor %} + {% endcomment %} + + {% render_form form %}
    Cancel diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 721c68e43..a6c606c4b 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -2,10 +2,10 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.utils.html import mark_safe +from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict from .models import Token, UserConfig -from .preferences import PREFERENCES class LoginForm(BootstrapMixin, AuthenticationForm): @@ -44,15 +44,6 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe class Meta: model = UserConfig fields = () - fieldsets = ( - ('User Interface', ( - 'pagination.per_page', - 'ui.colormode', - )), - ('Miscellaneous', ( - 'data_format', - )), - ) def __init__(self, *args, instance=None, **kwargs): diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py index 635393913..c66bc96c0 100644 --- a/netbox/users/preferences.py +++ b/netbox/users/preferences.py @@ -1,12 +1,3 @@ -from utilities.paginator import EnhancedPaginator - - -def get_page_lengths(): - return [ - (v, str(v)) for v in EnhancedPaginator.default_page_lengths - ] - - class UserPreference: def __init__(self, label, choices, default=None, description='', coerce=lambda x: x): @@ -15,33 +6,3 @@ class UserPreference: self.default = default if default is not None else choices[0] self.description = description self.coerce = coerce - - -PREFERENCES = { - - # User interface - 'ui.colormode': UserPreference( - label='Color mode', - choices=( - ('light', 'Light'), - ('dark', 'Dark'), - ), - default='light', - ), - 'pagination.per_page': UserPreference( - label='Page length', - choices=get_page_lengths(), - description='The number of objects to display per page', - coerce=lambda x: int(x) - ), - - # Miscellaneous - 'data_format': UserPreference( - label='Data format', - choices=( - ('json', 'JSON'), - ('yaml', 'YAML'), - ), - ), - -} From 7926225e9be8bfc64fd168ac63e92df19371de17 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 09:35:29 -0500 Subject: [PATCH 031/271] Improve preferences form rendering --- netbox/templates/users/preferences.html | 17 +++++++++++++---- netbox/users/forms.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 06a48b431..2a34f1b3f 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -8,20 +8,29 @@ {% csrf_token %} - {% comment %} {% for group, fields in form.Meta.fieldsets %}
    {{ group }}
    {% for name in fields %} - {% render_field form|getfield:name %} + {% render_field form|getfield:name %} {% endfor %}
    {% endfor %} - {% endcomment %} - {% render_form form %} + {% with plugin_fields=form.plugin_fields %} + {% if plugin_fields %} +
    +
    +
    Plugins
    +
    + {% for name in plugin_fields %} + {% render_field form|getfield:name %} + {% endfor %} +
    + {% endif %} + {% endwith %}
    Cancel diff --git a/netbox/users/forms.py b/netbox/users/forms.py index a6c606c4b..70e300a8c 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -44,6 +44,15 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe class Meta: model = UserConfig fields = () + fieldsets = ( + ('User Interface', ( + 'pagination.per_page', + 'ui.colormode', + )), + ('Miscellaneous', ( + 'data_format', + )), + ) def __init__(self, *args, instance=None, **kwargs): @@ -61,6 +70,12 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe return super().save(*args, **kwargs) + @property + def plugin_fields(self): + return [ + name for name in self.fields.keys() if name.startswith('plugins.') + ] + class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( From 01997efcbe4c03617a8b4e2803786c546c921c2b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 09:51:31 -0500 Subject: [PATCH 032/271] Add tests & cleanup --- netbox/users/preferences.py | 4 ++- netbox/users/tests/test_models.py | 2 -- netbox/users/tests/test_preferences.py | 39 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 netbox/users/tests/test_preferences.py diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py index c66bc96c0..cff6a3c9b 100644 --- a/netbox/users/preferences.py +++ b/netbox/users/preferences.py @@ -1,5 +1,7 @@ class UserPreference: - + """ + Represents a configurable user preference. + """ def __init__(self, label, choices, default=None, description='', coerce=lambda x: x): self.label = label self.choices = choices diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 8047796c4..48d440278 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,8 +1,6 @@ from django.contrib.auth.models import User from django.test import TestCase -from users.models import UserConfig - class UserConfigTest(TestCase): diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py new file mode 100644 index 000000000..23e94e8ef --- /dev/null +++ b/netbox/users/tests/test_preferences.py @@ -0,0 +1,39 @@ +from django.contrib.auth.models import User +from django.test import override_settings, TestCase + +from users.preferences import UserPreference + + +DEFAULT_USER_PREFERENCES = { + 'pagination': { + 'per_page': 250, + } +} + + +class UserPreferencesTest(TestCase): + + def test_userpreference(self): + CHOICES = ( + ('foo', 'Foo'), + ('bar', 'Bar'), + ) + kwargs = { + 'label': 'Test Preference', + 'choices': CHOICES, + 'default': CHOICES[0][0], + 'description': 'Description', + } + userpref = UserPreference(**kwargs) + + self.assertEqual(userpref.label, kwargs['label']) + self.assertEqual(userpref.choices, kwargs['choices']) + self.assertEqual(userpref.default, kwargs['default']) + self.assertEqual(userpref.description, kwargs['description']) + + @override_settings(DEFAULT_USER_PREFERENCES=DEFAULT_USER_PREFERENCES) + def test_default_preferences(self): + user = User.objects.create(username='User 1') + userconfig = user.config + + self.assertEqual(userconfig.data, DEFAULT_USER_PREFERENCES) From cb6342c87404264a5d46dac54c6cf2736321561e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 10:13:08 -0500 Subject: [PATCH 033/271] Reference DEFAULT_USER_PREFERENCES for undefined preferences --- netbox/users/models.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 7b768b57f..0afc7d374 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -80,13 +80,25 @@ class UserConfig(models.Model): keys = path.split('.') # Iterate down the hierarchy, returning the default value if any invalid key is encountered - for key in keys: - if type(d) is dict and key in d: + try: + for key in keys: d = d.get(key) - else: - return default + return d + except (AttributeError, KeyError): + pass - return d + # If the key is not found in the user's config, check for an application-wide default + config = get_config() + d = config.DEFAULT_USER_PREFERENCES + try: + for key in keys: + d = d.get(key) + return d + except (AttributeError, KeyError): + pass + + # Finally, return the specified default value (if any) + return default def all(self): """ From 7343ae73397089c6e8d45ec91d8f5df5bd65c754 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Dec 2021 10:45:21 -0500 Subject: [PATCH 034/271] Fix invalid key retrieval --- netbox/users/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 0afc7d374..0ce91363b 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -82,9 +82,9 @@ class UserConfig(models.Model): # Iterate down the hierarchy, returning the default value if any invalid key is encountered try: for key in keys: - d = d.get(key) + d = d[key] return d - except (AttributeError, KeyError): + except (TypeError, KeyError): pass # If the key is not found in the user's config, check for an application-wide default @@ -92,9 +92,9 @@ class UserConfig(models.Model): d = config.DEFAULT_USER_PREFERENCES try: for key in keys: - d = d.get(key) + d = d[key] return d - except (AttributeError, KeyError): + except (TypeError, KeyError): pass # Finally, return the specified default value (if any) From e0cfd5e49bda860353702affd435f62bf9322dd4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Dec 2021 10:14:28 -0500 Subject: [PATCH 035/271] Closes #2658: Avalable VLANs API endpoint for VLAN groups --- docs/release-notes/version-3.2.md | 4 ++ netbox/ipam/api/serializers.py | 34 ++++++++++++++ netbox/ipam/api/urls.py | 5 ++ netbox/ipam/api/views.py | 72 +++++++++++++++++++++++++++++ netbox/ipam/models/vlans.py | 10 ++++ netbox/ipam/tests/test_api.py | 76 +++++++++++++++++++++++++++++++ netbox/utilities/constants.py | 1 + 7 files changed, 202 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ad8c4697a..f03f1924f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,10 @@ ### New Features +#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) + +A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. + #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index aa1d2834a..14bad10b7 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -213,6 +213,40 @@ class VLANSerializer(PrimaryModelSerializer): ] +class AvailableVLANSerializer(serializers.Serializer): + """ + Representation of a VLAN which does not exist in the database. + """ + vid = serializers.IntegerField(read_only=True) + group = NestedVLANGroupSerializer(read_only=True) + + def to_representation(self, instance): + return OrderedDict([ + ('vid', instance), + ('group', NestedVLANGroupSerializer( + self.context['group'], + context={'request': self.context['request']} + ).data), + ]) + + +class CreateAvailableVLANSerializer(PrimaryModelSerializer): + site = NestedSiteSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceField(choices=VLANStatusChoices, required=False) + role = NestedRoleSerializer(required=False, allow_null=True) + + class Meta: + model = VLAN + fields = [ + 'name', 'site', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields', + ] + + def validate(self, data): + # Bypass model validation since we don't have a VID yet + return data + + # # Prefixes # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 26a36325f..3d69e258e 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -62,6 +62,11 @@ urlpatterns = [ views.PrefixAvailableIPAddressesView.as_view(), name='prefix-available-ips' ), + path( + 'vlan-groups//available-vlans/', + views.AvailableVLANsView.as_view(), + name='vlangroup-available-vlans' + ), ] urlpatterns += router.urls diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0d098db4b..de415cd81 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -327,3 +327,75 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView): def get_parent(self, request, pk): return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk) + + +class AvailableVLANsView(ObjectValidationMixin, APIView): + queryset = VLAN.objects.all() + + @swagger_auto_schema(responses={200: serializers.AvailableVLANSerializer(many=True)}) + def get(self, request, pk): + vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) + available_vlans = vlangroup.get_available_vids() + + serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={ + 'request': request, + 'group': vlangroup, + }) + + return Response(serializer.data) + + @swagger_auto_schema( + request_body=serializers.CreateAvailableVLANSerializer, + responses={201: serializers.VLANSerializer(many=True)} + ) + @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) + def post(self, request, pk): + self.queryset = self.queryset.restrict(request.user, 'add') + vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) + available_vlans = vlangroup.get_available_vids() + many = isinstance(request.data, list) + + # Validate requested VLANs + serializer = serializers.CreateAvailableVLANSerializer( + data=request.data if many else [request.data], + many=True, + context={ + 'request': request, + 'group': vlangroup, + } + ) + if not serializer.is_valid(): + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST + ) + + requested_vlans = serializer.validated_data + + for i, requested_vlan in enumerate(requested_vlans): + try: + requested_vlan['vid'] = available_vlans.pop(0) + requested_vlan['group'] = vlangroup.pk + except IndexError: + return Response({ + "detail": "The requested number of VLANs is not available" + }, status=status.HTTP_409_CONFLICT) + + # Initialize the serializer with a list or a single object depending on what was requested + context = {'request': request} + if many: + serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context) + else: + serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context) + + # Create the new VLAN(s) + if serializer.is_valid(): + try: + with transaction.atomic(): + created = serializer.save() + self._validate_objects(created) + except ObjectDoesNotExist: + raise PermissionDenied() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 3a1725770..c31bb49fd 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -75,6 +75,16 @@ class VLANGroup(OrganizationalModel): if self.scope_id and not self.scope_type: raise ValidationError("Cannot set scope_id without scope_type.") + def get_available_vids(self): + """ + Return all available VLANs within this group. + """ + available_vlans = {vid for vid in range(VLAN_VID_MIN, VLAN_VID_MAX + 1)} + available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True)) + + # TODO: Check ordering + return list(available_vlans) + def get_next_available_vid(self): """ Return the first available VLAN ID (1-4094) in the group. diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 478c7f29b..1806d3bec 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -695,6 +695,82 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): ) VLANGroup.objects.bulk_create(vlan_groups) + def test_list_available_vlans(self): + """ + Test retrieval of all available VLANs within a group. + """ + self.add_permissions('ipam.view_vlan') + vlangroup = VLANGroup.objects.first() + + vlans = ( + VLAN(vid=10, name='VLAN 10', group=vlangroup), + VLAN(vid=20, name='VLAN 20', group=vlangroup), + VLAN(vid=30, name='VLAN 30', group=vlangroup), + ) + VLAN.objects.bulk_create(vlans) + + # Retrieve all available VLANs + url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(len(response.data), 4094 - len(vlans)) + available_vlans = {vlan['vid'] for vlan in response.data} + for vlan in vlans: + self.assertNotIn(vlan.vid, available_vlans) + + def test_create_single_available_vlan(self): + """ + Test the creation of a single available VLAN. + """ + self.add_permissions('ipam.view_vlan', 'ipam.add_vlan') + vlangroup = VLANGroup.objects.first() + VLAN.objects.create(vid=1, name='VLAN 1', group=vlangroup) + + data = { + "name": "First VLAN", + } + url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk}) + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['group']['id'], vlangroup.pk) + self.assertEqual(response.data['vid'], 2) + + def test_create_multiple_available_vlans(self): + """ + Test the creation of multiple available VLANs. + """ + self.add_permissions('ipam.view_vlan', 'ipam.add_vlan') + vlangroup = VLANGroup.objects.first() + + vlans = ( + VLAN(vid=1, name='VLAN 1', group=vlangroup), + VLAN(vid=3, name='VLAN 3', group=vlangroup), + VLAN(vid=5, name='VLAN 5', group=vlangroup), + ) + VLAN.objects.bulk_create(vlans) + + data = ( + {"name": "First VLAN"}, + {"name": "Second VLAN"}, + {"name": "Third VLAN"}, + ) + url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk}) + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(len(response.data), 3) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[0]['group']['id'], vlangroup.pk) + self.assertEqual(response.data[0]['vid'], 2) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[1]['group']['id'], vlangroup.pk) + self.assertEqual(response.data[1]['vid'], 4) + self.assertEqual(response.data[2]['name'], data[2]['name']) + self.assertEqual(response.data[2]['group']['id'], vlangroup.pk) + self.assertEqual(response.data[2]['vid'], 6) + class VLANTest(APIViewTestCases.APIViewTestCase): model = VLAN diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 08e9dd9cf..9303e5f3a 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -42,6 +42,7 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict( ADVISORY_LOCK_KEYS = { 'available-prefixes': 100100, 'available-ips': 100200, + 'available-vlans': 100300, } # From 083fda31723bac288d10342e841dc4699ac41345 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Dec 2021 10:46:57 -0500 Subject: [PATCH 036/271] #2658: Fix test permissions --- netbox/ipam/tests/test_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 1806d3bec..dfbf1a971 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -699,7 +699,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): """ Test retrieval of all available VLANs within a group. """ - self.add_permissions('ipam.view_vlan') + self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan') vlangroup = VLANGroup.objects.first() vlans = ( @@ -722,7 +722,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): """ Test the creation of a single available VLAN. """ - self.add_permissions('ipam.view_vlan', 'ipam.add_vlan') + self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan', 'ipam.add_vlan') vlangroup = VLANGroup.objects.first() VLAN.objects.create(vid=1, name='VLAN 1', group=vlangroup) @@ -741,7 +741,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): """ Test the creation of multiple available VLANs. """ - self.add_permissions('ipam.view_vlan', 'ipam.add_vlan') + self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan', 'ipam.add_vlan') vlangroup = VLANGroup.objects.first() vlans = ( From 544d991e1e614b97f36fa1b58900189e0f41bbc9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Dec 2021 11:13:28 -0500 Subject: [PATCH 037/271] Closes #8168: Add min/max VID fields to VLANGroup --- docs/models/ipam/vlangroup.md | 2 + docs/release-notes/version-3.2.md | 4 ++ netbox/ipam/api/serializers.py | 4 +- netbox/ipam/filtersets.py | 2 +- netbox/ipam/forms/bulk_edit.py | 12 +++++ netbox/ipam/forms/bulk_import.py | 14 +++++- netbox/ipam/forms/filtersets.py | 11 ++++- netbox/ipam/forms/models.py | 3 +- .../migrations/0054_vlangroup_min_max_vids.py | 24 ++++++++++ netbox/ipam/models/vlans.py | 48 +++++++++++++++---- netbox/ipam/tables/vlans.py | 5 +- netbox/ipam/tests/test_models.py | 40 +++++++++++----- netbox/ipam/tests/test_views.py | 2 + netbox/templates/ipam/vlangroup.html | 8 ++-- 14 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 netbox/ipam/migrations/0054_vlangroup_min_max_vids.py diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 819d45982..2840fafed 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -2,4 +2,6 @@ VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. +A minimum and maximum child VLAN ID must be set for each group. (These default to 1 and 4094 respectively.) VLANs created within a group must have a VID that falls between these values (inclusive). + Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index f03f1924f..693335ae9 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -41,6 +41,7 @@ FIELD_CHOICES = { ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation +* [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group ### Other Changes @@ -53,3 +54,6 @@ FIELD_CHOICES = { * dcim.Site * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields +* ipam.VLANGroup + * Added the `/availables-vlans/` endpoint + * Added the `min_vid` and `max_vid` fields diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 14bad10b7..c028a3d5d 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -182,8 +182,8 @@ class VLANGroupSerializer(PrimaryModelSerializer): class Meta: model = VLANGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', ] validators = [] diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index df6ee1055..8a10a7b24 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -740,7 +740,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'description', 'scope_id'] + fields = ['id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index edb14a25c..971becaed 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -359,6 +359,18 @@ class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): queryset=Site.objects.all(), required=False ) + min_vid = forms.IntegerField( + min_value=VLAN_VID_MIN, + max_value=VLAN_VID_MAX, + required=False, + label='Minimum child VLAN VID' + ) + max_vid = forms.IntegerField( + min_value=VLAN_VID_MIN, + max_value=VLAN_VID_MAX, + required=False, + label='Maximum child VLAN VID' + ) description = forms.CharField( max_length=200, required=False diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 65fc35c34..a4fdaa3ae 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -332,10 +332,22 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm): required=False, label='Scope type (app & model)' ) + min_vid = forms.IntegerField( + min_value=VLAN_VID_MIN, + max_value=VLAN_VID_MAX, + required=False, + label=f'Minimum child VLAN VID (default: {VLAN_VID_MIN})' + ) + max_vid = forms.IntegerField( + min_value=VLAN_VID_MIN, + max_value=VLAN_VID_MAX, + required=False, + label=f'Maximum child VLAN VID (default: {VLAN_VID_MIN})' + ) class Meta: model = VLANGroup - fields = ('name', 'slug', 'scope_type', 'scope_id', 'description') + fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description') labels = { 'scope_id': 'Scope ID', } diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index b21dbd6cd..a7732fe9a 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -370,7 +370,8 @@ class FHRPGroupFilterForm(CustomFieldModelFilterForm): class VLANGroupFilterForm(CustomFieldModelFilterForm): field_groups = [ ['q', 'tag'], - ['region', 'sitegroup', 'site', 'location', 'rack'] + ['region', 'sitegroup', 'site', 'location', 'rack'], + ['min_vid', 'max_vid'], ] model = VLANGroup region = DynamicModelMultipleChoiceField( @@ -403,6 +404,14 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm): label=_('Rack'), fetch_trigger='open' ) + min_vid = forms.IntegerField( + min_value=VLAN_VID_MIN, + max_value=VLAN_VID_MAX, + ) + max_vid = forms.IntegerField( + min_value=VLAN_VID_MIN, + max_value=VLAN_VID_MAX, + ) tag = TagFilterField(model) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index c5e3146e9..68eac5456 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -700,10 +700,11 @@ class VLANGroupForm(CustomFieldModelForm): model = VLANGroup fields = [ 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', - 'clustergroup', 'cluster', 'tags', + 'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags', ] fieldsets = ( ('VLAN Group', ('name', 'slug', 'description', 'tags')), + ('Child VLANs', ('min_vid', 'max_vid')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) widgets = { diff --git a/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py b/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py new file mode 100644 index 000000000..adbe69f4c --- /dev/null +++ b/netbox/ipam/migrations/0054_vlangroup_min_max_vids.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.10 on 2021-12-23 15:24 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0053_asn_model'), + ] + + operations = [ + migrations.AddField( + model_name='vlangroup', + name='max_vid', + field=models.PositiveSmallIntegerField(default=4094, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)]), + ), + migrations.AddField( + model_name='vlangroup', + name='min_vid', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)]), + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index c31bb49fd..31c8da2b6 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -46,6 +46,24 @@ class VLANGroup(OrganizationalModel): ct_field='scope_type', fk_field='scope_id' ) + min_vid = models.PositiveSmallIntegerField( + verbose_name='Minimum VLAN ID', + default=VLAN_VID_MIN, + validators=( + MinValueValidator(VLAN_VID_MIN), + MaxValueValidator(VLAN_VID_MAX) + ), + help_text='Lowest permissible ID of a child VLAN' + ) + max_vid = models.PositiveSmallIntegerField( + verbose_name='Maximum VLAN ID', + default=VLAN_VID_MAX, + validators=( + MinValueValidator(VLAN_VID_MIN), + MaxValueValidator(VLAN_VID_MAX) + ), + help_text='Highest permissible ID of a child VLAN' + ) description = models.CharField( max_length=200, blank=True @@ -75,24 +93,28 @@ class VLANGroup(OrganizationalModel): if self.scope_id and not self.scope_type: raise ValidationError("Cannot set scope_id without scope_type.") + # Validate min/max child VID limits + if self.max_vid < self.min_vid: + raise ValidationError({ + 'max_vid': "Maximum child VID must be greater than or equal to minimum child VID" + }) + def get_available_vids(self): """ Return all available VLANs within this group. """ - available_vlans = {vid for vid in range(VLAN_VID_MIN, VLAN_VID_MAX + 1)} + available_vlans = {vid for vid in range(self.min_vid, self.max_vid + 1)} available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True)) - # TODO: Check ordering - return list(available_vlans) + return sorted(available_vlans) def get_next_available_vid(self): """ Return the first available VLAN ID (1-4094) in the group. """ - vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) - for i in range(1, 4095): - if i not in vlan_ids: - return i + available_vids = self.get_available_vids() + if available_vids: + return available_vids[0] return None @@ -122,7 +144,10 @@ class VLAN(PrimaryModel): ) vid = models.PositiveSmallIntegerField( verbose_name='ID', - validators=[MinValueValidator(1), MaxValueValidator(4094)] + validators=( + MinValueValidator(VLAN_VID_MIN), + MaxValueValidator(VLAN_VID_MAX) + ) ) name = models.CharField( max_length=64 @@ -182,6 +207,13 @@ class VLAN(PrimaryModel): f"site {self.site}." }) + # Validate group min/max VIDs + if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: + raise ValidationError({ + 'vid': f"VID must be between {self.group.min_vid} and {self.group.max_vid} for VLANs in group " + f"{self.group}" + }) + def get_status_class(self): return VLANStatusChoices.colors.get(self.status, 'secondary') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 365c6119b..ca8d22552 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -84,7 +84,10 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = VLANGroup - fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', + 'tags', 'actions', + ) default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index f6130f1c1..06ac9b843 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -497,18 +497,32 @@ class TestIPAddress(TestCase): class TestVLANGroup(TestCase): + @classmethod + def setUpTestData(cls): + vlangroup = VLANGroup.objects.create( + name='VLAN Group 1', + slug='vlan-group-1', + min_vid=100, + max_vid=199 + ) + VLAN.objects.bulk_create(( + VLAN(name='VLAN 100', vid=100, group=vlangroup), + VLAN(name='VLAN 101', vid=101, group=vlangroup), + VLAN(name='VLAN 102', vid=102, group=vlangroup), + VLAN(name='VLAN 103', vid=103, group=vlangroup), + )) + + def test_get_available_vids(self): + vlangroup = VLANGroup.objects.first() + child_vids = VLAN.objects.filter(group=vlangroup).values_list('vid', flat=True) + self.assertEqual(len(child_vids), 4) + + available_vids = vlangroup.get_available_vids() + self.assertListEqual(available_vids, list(range(104, 200))) + def test_get_next_available_vid(self): + vlangroup = VLANGroup.objects.first() + self.assertEqual(vlangroup.get_next_available_vid(), 104) - vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1') - VLAN.objects.bulk_create(( - VLAN(name='VLAN 1', vid=1, group=vlangroup), - VLAN(name='VLAN 2', vid=2, group=vlangroup), - VLAN(name='VLAN 3', vid=3, group=vlangroup), - VLAN(name='VLAN 5', vid=5, group=vlangroup), - )) - self.assertEqual(vlangroup.get_next_available_vid(), 4) - - VLAN.objects.bulk_create(( - VLAN(name='VLAN 4', vid=4, group=vlangroup), - )) - self.assertEqual(vlangroup.get_next_available_vid(), 6) + VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup) + self.assertEqual(vlangroup.get_next_available_vid(), 105) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 022ea13c3..1e7a72389 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -485,6 +485,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', + 'min_vid': 1, + 'max_vid': 4094, 'description': 'A new VLAN group', 'tags': [t.pk for t in tags], } diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index b0e2b1a21..f92afce46 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -23,9 +23,7 @@
    -
    - VLAN Group -
    +
    VLAN Group
    @@ -45,6 +43,10 @@ {% endif %} + + + + + + + + From 77dd68491643b92cb745ee9d2907f14bceadeb0c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Dec 2021 14:20:03 -0500 Subject: [PATCH 040/271] Closes #7784: Support cluster type assignment for config contexts --- docs/release-notes/version-3.2.md | 3 + netbox/extras/api/serializers.py | 16 ++++-- netbox/extras/filtersets.py | 13 ++++- netbox/extras/forms/filtersets.py | 10 +++- netbox/extras/forms/models.py | 8 ++- .../0067_configcontext_cluster_types.py | 17 ++++++ netbox/extras/models/configcontexts.py | 5 ++ netbox/extras/querysets.py | 5 +- netbox/extras/tables.py | 2 +- netbox/extras/tests/test_filtersets.py | 22 +++++-- netbox/extras/tests/test_models.py | 57 +++++++++---------- netbox/extras/views.py | 1 + .../templates/extras/configcontext_edit.html | 1 + 13 files changed, 115 insertions(+), 45 deletions(-) create mode 100644 netbox/extras/migrations/0067_configcontext_cluster_types.py diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 2944b5ee9..f35100c72 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -43,6 +43,7 @@ FIELD_CHOICES = { * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form +* [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group ### Other Changes @@ -77,6 +78,8 @@ FIELD_CHOICES = { * Added `module` field * dcim.Site * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields +* extras.ConfigContext + * Add `cluster_types` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint * Added the `min_vid` and `max_vid` fields diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 9e4665cc2..fa0e5189f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -19,8 +19,10 @@ from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantG from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model -from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer -from virtualization.models import Cluster, ClusterGroup +from virtualization.api.nested_serializers import ( + NestedClusterGroupSerializer, NestedClusterSerializer, NestedClusterTypeSerializer, +) +from virtualization.models import Cluster, ClusterGroup, ClusterType from .nested_serializers import * __all__ = ( @@ -267,6 +269,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + cluster_types = SerializedPKRelatedField( + queryset=ClusterType.objects.all(), + serializer=NestedClusterTypeSerializer, + required=False, + many=True + ) cluster_groups = SerializedPKRelatedField( queryset=ClusterGroup.objects.all(), serializer=NestedClusterGroupSerializer, @@ -302,8 +310,8 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'device_types', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', - 'data', 'created', 'last_updated', + 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'tags', 'data', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 6233ca442..bf25ff76c 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -7,7 +7,7 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet from tenancy.models import Tenant, TenantGroup from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter -from virtualization.models import Cluster, ClusterGroup +from virtualization.models import Cluster, ClusterGroup, ClusterType from .choices import * from .models import * @@ -279,6 +279,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): to_field_name='slug', label='Platform (slug)', ) + cluster_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='cluster_types', + queryset=ClusterType.objects.all(), + label='Cluster type', + ) + cluster_type = django_filters.ModelMultipleChoiceFilter( + field_name='cluster_types__slug', + queryset=ClusterType.objects.all(), + to_field_name='slug', + label='Cluster type (slug)', + ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster_groups', queryset=ClusterGroup.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 07375a203..29527c20e 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -12,7 +12,7 @@ from utilities.forms import ( add_blank_choice, APISelectMultiple, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import Cluster, ClusterGroup +from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( 'ConfigContextFilterForm', @@ -158,7 +158,7 @@ class ConfigContextFilterForm(FilterForm): ['q', 'tag'], ['region_id', 'site_group_id', 'site_id'], ['device_type_id', 'platform_id', 'role_id'], - ['cluster_group_id', 'cluster_id'], + ['cluster_type_id', 'cluster_group_id', 'cluster_id'], ['tenant_group_id', 'tenant_id'] ] region_id = DynamicModelMultipleChoiceField( @@ -197,6 +197,12 @@ class ConfigContextFilterForm(FilterForm): label=_('Platforms'), fetch_trigger='open' ) + cluster_type_id = DynamicModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + required=False, + label=_('Cluster types'), + fetch_trigger='open' + ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 1e619ebec..d75214722 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -10,7 +10,7 @@ from utilities.forms import ( add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, ) -from virtualization.models import Cluster, ClusterGroup +from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( 'AddRemoveTagsForm', @@ -165,6 +165,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Platform.objects.all(), required=False ) + cluster_types = DynamicModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + required=False + ) cluster_groups = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False @@ -193,7 +197,7 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): model = ConfigContext fields = ( 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', - 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ) diff --git a/netbox/extras/migrations/0067_configcontext_cluster_types.py b/netbox/extras/migrations/0067_configcontext_cluster_types.py new file mode 100644 index 000000000..f9376da77 --- /dev/null +++ b/netbox/extras/migrations/0067_configcontext_cluster_types.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0026_vminterface_bridge'), + ('extras', '0066_customfield_name_validation'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='cluster_types', + field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_cluster_types_+', to='virtualization.ClusterType'), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 8c142de8b..2a14f143f 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -71,6 +71,11 @@ class ConfigContext(ChangeLoggedModel): related_name='+', blank=True ) + cluster_types = models.ManyToManyField( + to='virtualization.ClusterType', + related_name='+', + blank=True + ) cluster_groups = models.ManyToManyField( to='virtualization.ClusterGroup', related_name='+', diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 59d16fff8..982f33d02 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -22,8 +22,9 @@ class ConfigContextQuerySet(RestrictedQuerySet): # Device type assignment is relevant only for Devices device_type = getattr(obj, 'device_type', None) - # Get assigned Cluster and ClusterGroup, if any + # Get assigned cluster, group, and type (if any) cluster = getattr(obj, 'cluster', None) + cluster_type = getattr(cluster, 'type', None) cluster_group = getattr(cluster, 'group', None) # Get the group of the assigned tenant, if any @@ -44,6 +45,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), + Q(cluster_types=cluster_type) | Q(cluster_types=None), Q(cluster_groups=cluster_group) | Q(cluster_groups=None), Q(clusters=cluster) | Q(clusters=None), Q(tenant_groups=tenant_group) | Q(tenant_groups=None), @@ -93,6 +95,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): } base_query = Q( Q(platforms=OuterRef('platform')) | Q(platforms=None), + Q(cluster_types=OuterRef('cluster__type')) | Q(cluster_types=None), Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None), Q(clusters=OuterRef('cluster')) | Q(clusters=None), Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None), diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 266f2089a..62317e636 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -193,7 +193,7 @@ class ConfigContextTable(BaseTable): model = ConfigContext fields = ( 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', - 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 0f4b35cf6..a5f77afa9 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -399,6 +399,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): ) Platform.objects.bulk_create(platforms) + cluster_types = ( + ClusterType(name='Cluster Type 1', slug='cluster-type-1'), + ClusterType(name='Cluster Type 2', slug='cluster-type-2'), + ClusterType(name='Cluster Type 3', slug='cluster-type-3'), + ) + ClusterType.objects.bulk_create(cluster_types) + cluster_groups = ( ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), @@ -406,11 +413,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): ) ClusterGroup.objects.bulk_create(cluster_groups) - cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=cluster_type), - Cluster(name='Cluster 2', type=cluster_type), - Cluster(name='Cluster 3', type=cluster_type), + Cluster(name='Cluster 1', type=cluster_types[0]), + Cluster(name='Cluster 2', type=cluster_types[1]), + Cluster(name='Cluster 3', type=cluster_types[2]), ) Cluster.objects.bulk_create(clusters) @@ -442,6 +448,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): c.device_types.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) + c.cluster_types.set([cluster_types[i]]) c.cluster_groups.set([cluster_groups[i]]) c.clusters.set([clusters[i]]) c.tenant_groups.set([tenant_groups[i]]) @@ -504,6 +511,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cluster_type(self): + cluster_types = ClusterType.objects.all()[:2] + params = {'cluster_type_id': [cluster_types[0].pk, cluster_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'cluster_type': [cluster_types[0].slug, cluster_types[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cluster(self): clusters = Cluster.objects.all()[:2] params = {'cluster_id': [clusters[0].pk, clusters[1].pk]} diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 10d4168b4..17138d42b 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -216,80 +216,77 @@ class ConfigContextTest(TestCase): self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_virtualmachine_relations(self): + cluster_type = ClusterType.objects.create(name="Cluster Type") + cluster_group = ClusterGroup.objects.create(name="Cluster Group") + cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) site_context = ConfigContext.objects.create( name="site", weight=100, - data={ - "site": 1 - } + data={"site": 1} ) site_context.sites.add(self.site) + region_context = ConfigContext.objects.create( name="region", weight=100, - data={ - "region": 1 - } + data={"region": 1} ) region_context.regions.add(self.region) + sitegroup_context = ConfigContext.objects.create( name="sitegroup", weight=100, - data={ - "sitegroup": 1 - } + data={"sitegroup": 1} ) sitegroup_context.site_groups.add(self.sitegroup) + platform_context = ConfigContext.objects.create( name="platform", weight=100, - data={ - "platform": 1 - } + data={"platform": 1} ) platform_context.platforms.add(self.platform) + tenant_group_context = ConfigContext.objects.create( name="tenant group", weight=100, - data={ - "tenant_group": 1 - } + data={"tenant_group": 1} ) tenant_group_context.tenant_groups.add(self.tenantgroup) + tenant_context = ConfigContext.objects.create( name="tenant", weight=100, - data={ - "tenant": 1 - } + data={"tenant": 1} ) tenant_context.tenants.add(self.tenant) + tag_context = ConfigContext.objects.create( name="tag", weight=100, - data={ - "tag": 1 - } + data={"tag": 1} ) tag_context.tags.add(self.tag) - cluster_group = ClusterGroup.objects.create(name="Cluster Group") + + cluster_type_context = ConfigContext.objects.create( + name="cluster type", + weight=100, + data={"cluster_type": 1} + ) + cluster_type_context.cluster_types.add(cluster_type) + cluster_group_context = ConfigContext.objects.create( name="cluster group", weight=100, - data={ - "cluster_group": 1 - } + data={"cluster_group": 1} ) cluster_group_context.cluster_groups.add(cluster_group) - cluster_type = ClusterType.objects.create(name="Cluster Type 1") - cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) + cluster_context = ConfigContext.objects.create( name="cluster", weight=100, - data={ - "cluster": 1 - } + data={"cluster": 1} ) cluster_context.clusters.add(cluster) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 256709c6a..1660f8210 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -285,6 +285,7 @@ class ConfigContextView(generic.ObjectView): ('Device Types', instance.device_types.all), ('Roles', instance.roles.all), ('Platforms', instance.platforms.all), + ('Cluster Types', instance.cluster_types.all), ('Cluster Groups', instance.cluster_groups.all), ('Clusters', instance.clusters.all), ('Tenant Groups', instance.tenant_groups.all), diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 4e4506dc6..7b37a69c6 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -20,6 +20,7 @@ {% render_field form.device_types %} {% render_field form.roles %} {% render_field form.platforms %} + {% render_field form.cluster_types %} {% render_field form.cluster_groups %} {% render_field form.clusters %} {% render_field form.tenant_groups %} From 04fb5e544d2d23622f82cc2d837182eabb123eea Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 10:18:39 -0500 Subject: [PATCH 041/271] #3087: Add InvetoryItemRole --- docs/models/dcim/inventoryitemrole.md | 3 ++ netbox/dcim/api/nested_serializers.py | 10 ++++ netbox/dcim/api/serializers.py | 20 +++++-- netbox/dcim/api/urls.py | 3 ++ netbox/dcim/api/views.py | 12 +++++ netbox/dcim/filtersets.py | 9 ++++ netbox/dcim/forms/bulk_edit.py | 22 ++++++++ netbox/dcim/forms/bulk_import.py | 28 ++++++++++ netbox/dcim/forms/filtersets.py | 10 ++++ netbox/dcim/forms/models.py | 19 +++++++ netbox/dcim/graphql/schema.py | 3 ++ netbox/dcim/graphql/types.py | 9 ++++ .../dcim/migrations/0146_inventoryitemrole.py | 38 +++++++++++++ netbox/dcim/models/device_components.py | 45 +++++++++++++++- netbox/dcim/tables/devices.py | 31 +++++++++-- netbox/dcim/tests/test_api.py | 35 ++++++++++++ netbox/dcim/tests/test_filtersets.py | 27 ++++++++++ netbox/dcim/tests/test_views.py | 37 ++++++++++++- netbox/dcim/urls.py | 11 ++++ netbox/dcim/views.py | 53 +++++++++++++++++++ netbox/netbox/navigation_menu.py | 1 + netbox/templates/dcim/inventoryitemrole.html | 53 +++++++++++++++++++ 22 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 docs/models/dcim/inventoryitemrole.md create mode 100644 netbox/dcim/migrations/0146_inventoryitemrole.py create mode 100644 netbox/templates/dcim/inventoryitemrole.html diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md new file mode 100644 index 000000000..8ed31481a --- /dev/null +++ b/docs/models/dcim/inventoryitemrole.md @@ -0,0 +1,3 @@ +# Inventory Item Roles + +Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc. diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 9440e5d4b..0cd112a1d 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -20,6 +20,7 @@ __all__ = [ 'NestedInterfaceSerializer', 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', + 'NestedInventoryItemRoleSerializer', 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', @@ -384,6 +385,15 @@ class NestedInventoryItemSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'device', 'name', '_depth'] +class NestedInventoryItemRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + inventoryitem_count = serializers.IntegerField(read_only=True) + + class Meta: + model = models.InventoryItemRole + fields = ['id', 'url', 'display', 'name', 'slug', 'inventoryitem_count'] + + # # Cables # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index cf6c89333..fe8487411 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -806,10 +806,6 @@ class DeviceBaySerializer(PrimaryModelSerializer): ] -# -# Inventory items -# - class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() @@ -825,6 +821,22 @@ class InventoryItemSerializer(PrimaryModelSerializer): ] +# +# Device component roles +# + +class InventoryItemRoleSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + inventoryitem_count = serializers.IntegerField(read_only=True) + + class Meta: + model = InventoryItemRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'inventoryitem_count', + ] + + # # Cables # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 71a768fd5..be963d36d 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -50,6 +50,9 @@ router.register('module-bays', views.ModuleBayViewSet) router.register('device-bays', views.DeviceBayViewSet) router.register('inventory-items', views.InventoryItemViewSet) +# Device component roles +router.register('inventory-item-roles', views.InventoryItemRoleViewSet) + # Cables router.register('cables', views.CableViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8838eda2c..479abf7b2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -623,6 +623,18 @@ class InventoryItemViewSet(ModelViewSet): brief_prefetch_fields = ['device'] +# +# Device component roles +# + +class InventoryItemRoleViewSet(CustomFieldModelViewSet): + queryset = InventoryItemRole.objects.prefetch_related('tags').annotate( + inventoryitem_count=count_related(InventoryItem, 'role') + ) + serializer_class = serializers.InventoryItemRoleSerializer + filterset_class = filtersets.InventoryItemRoleFilterSet + + # # Cables # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d91a9b574..5f4840fde 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -39,6 +39,7 @@ __all__ = ( 'InterfaceFilterSet', 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', + 'InventoryItemRoleFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', 'ModuleBayFilterSet', @@ -1304,6 +1305,14 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): return queryset.filter(qs_filter) +class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() + + class Meta: + model = InventoryItemRole + fields = ['id', 'name', 'slug', 'color'] + + class VirtualChassisFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d40ac6fca..8fc8835cb 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -30,6 +30,7 @@ __all__ = ( 'InterfaceBulkEditForm', 'InterfaceTemplateBulkEditForm', 'InventoryItemBulkEditForm', + 'InventoryItemRoleBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', 'ModuleBulkEditForm', @@ -1186,3 +1187,24 @@ class InventoryItemBulkEditForm( class Meta: nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] + + +# +# Device component roles +# + +class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItemRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = ColorField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 23d589abf..40838c60c 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -24,6 +24,7 @@ __all__ = ( 'FrontPortCSVForm', 'InterfaceCSVForm', 'InventoryItemCSVForm', + 'InventoryItemRoleCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', 'ModuleCSVForm', @@ -805,6 +806,25 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): self.fields['parent'].queryset = InventoryItem.objects.none() +# +# Device component roles +# + +class InventoryItemRoleCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = InventoryItemRole + fields = ('name', 'slug', 'color', 'description') + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + +# +# Cables +# + class CableCSVForm(CustomFieldModelCSVForm): # Termination A side_a_device = CSVModelChoiceField( @@ -906,6 +926,10 @@ class CableCSVForm(CustomFieldModelCSVForm): return length_unit if length_unit is not None else '' +# +# Virtual chassis +# + class VirtualChassisCSVForm(CustomFieldModelCSVForm): master = CSVModelChoiceField( queryset=Device.objects.all(), @@ -919,6 +943,10 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm): fields = ('name', 'domain', 'master') +# +# Power +# + class PowerPanelCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 819cb91cc..ae58e1d2f 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -27,6 +27,7 @@ __all__ = ( 'InterfaceConnectionFilterForm', 'InterfaceFilterForm', 'InventoryItemFilterForm', + 'InventoryItemRoleFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', 'ModuleFilterForm', @@ -1120,6 +1121,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) +# +# Device component roles +# + +class InventoryItemRoleFilterForm(CustomFieldModelFilterForm): + model = InventoryItemRole + tag = TagFilterField(model) + + # # Connections # diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2d32093c4..b5b15e731 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -37,6 +37,7 @@ __all__ = ( 'InterfaceForm', 'InterfaceTemplateForm', 'InventoryItemForm', + 'InventoryItemRoleForm', 'LocationForm', 'ManufacturerForm', 'ModuleForm', @@ -1382,3 +1383,21 @@ class InventoryItemForm(CustomFieldModelForm): 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', ] + + +# +# Device component roles +# + +class InventoryItemRoleForm(CustomFieldModelForm): + slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = InventoryItemRole + fields = [ + 'name', 'slug', 'color', 'description', 'tags', + ] diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 7f660b192..8e03ab409 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -50,6 +50,9 @@ class DCIMQuery(graphene.ObjectType): inventory_item = ObjectField(InventoryItemType) inventory_item_list = ObjectListField(InventoryItemType) + inventory_item_role = ObjectField(InventoryItemRoleType) + inventory_item_role_list = ObjectListField(InventoryItemRoleType) + location = ObjectField(LocationType) location_list = ObjectListField(LocationType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 51e196076..b2a94c3ed 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -25,6 +25,7 @@ __all__ = ( 'InterfaceType', 'InterfaceTemplateType', 'InventoryItemType', + 'InventoryItemRoleType', 'LocationType', 'ManufacturerType', 'ModuleType', @@ -242,6 +243,14 @@ class InventoryItemType(ComponentObjectType): filterset_class = filtersets.InventoryItemFilterSet +class InventoryItemRoleType(OrganizationalObjectType): + + class Meta: + model = models.InventoryItemRole + fields = '__all__' + filterset_class = filtersets.InventoryItemRoleFilterSet + + class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType): class Meta: diff --git a/netbox/dcim/migrations/0146_inventoryitemrole.py b/netbox/dcim/migrations/0146_inventoryitemrole.py new file mode 100644 index 000000000..97de677f8 --- /dev/null +++ b/netbox/dcim/migrations/0146_inventoryitemrole.py @@ -0,0 +1,38 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0067_configcontext_cluster_types'), + ('dcim', '0145_modules'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItemRole', + 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)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('color', utilities.fields.ColorField(default='9e9e9e', max_length=6)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='inventoryitem', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.inventoryitemrole'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index ccfe538d7..5329c9e01 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -12,7 +12,8 @@ from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import OrganizationalModel, PrimaryModel +from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -30,6 +31,7 @@ __all__ = ( 'FrontPort', 'Interface', 'InventoryItem', + 'InventoryItemRole', 'ModuleBay', 'PathEndpoint', 'PowerOutlet', @@ -946,6 +948,38 @@ class DeviceBay(ComponentModel): # Inventory items # + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class InventoryItemRole(OrganizationalModel): + """ + Inventory items may optionally be assigned a functional role. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + color = ColorField( + default=ColorChoices.COLOR_GREY + ) + description = models.CharField( + max_length=200, + blank=True, + ) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:inventoryitemrole', args=[self.pk]) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ @@ -973,6 +1007,13 @@ class InventoryItem(MPTTModel, ComponentModel): blank=True, help_text='Manufacturer-assigned part identifier' ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_items', + blank=True, + null=True + ) serial = models.CharField( max_length=50, verbose_name='Serial number', @@ -993,7 +1034,7 @@ class InventoryItem(MPTTModel, ComponentModel): objects = TreeManager() - clone_fields = ['device', 'parent', 'manufacturer', 'part_id'] + clone_fields = ['device', 'parent', 'manufacturer', 'part_id', 'role'] class Meta: ordering = ('device__id', 'parent__id', '_name') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7805e60c1..f889d52ec 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -2,8 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, ModuleBay, - Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, + InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) from tenancy.tables import TenantColumn from utilities.tables import ( @@ -33,6 +33,7 @@ __all__ = ( 'DeviceTable', 'FrontPortTable', 'InterfaceTable', + 'InventoryItemRoleTable', 'InventoryItemTable', 'ModuleBayTable', 'PlatformTable', @@ -68,11 +69,11 @@ def get_interface_state_attribute(record): else: return "disabled" + # # Device roles # - class DeviceRoleTable(BaseTable): pk = ToggleColumn() name = tables.Column( @@ -791,6 +792,30 @@ class InventoryItemTable(DeviceComponentTable): default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') +class InventoryItemRoleTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + inventoryitem_count = LinkedCountColumn( + viewname='dcim:inventoryitem_list', + url_params={'role_id': 'pk'}, + verbose_name='Items' + ) + color = ColorColumn() + tags = TagColumn( + url_name='dcim:inventoryitemrole_list' + ) + actions = ButtonsColumn(InventoryItemRole) + + class Meta(BaseTable.Meta): + model = InventoryItemRole + fields = ( + 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', + ) + default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions') + + class DeviceInventoryItemTable(InventoryItemTable): name = tables.TemplateColumn( template_code='' diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 3b6410c8c..a6c7760f4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1649,6 +1649,41 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): ] +class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase): + model = InventoryItemRole + brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url'] + create_data = [ + { + 'name': 'Inventory Item Role 4', + 'slug': 'inventory-item-role-4', + 'color': 'ffff00', + }, + { + 'name': 'Inventory Item Role 5', + 'slug': 'inventory-item-role-5', + 'color': 'ffff00', + }, + { + 'name': 'Inventory Item Role 6', + 'slug': 'inventory-item-role-6', + 'color': 'ffff00', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + roles = ( + InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1', color='ff0000'), + InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2', color='00ff00'), + InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3', color='0000ff'), + ) + InventoryItemRole.objects.bulk_create(roles) + + class CableTest(APIViewTestCases.APIViewTestCase): model = Cable brief_fields = ['display', 'id', 'label', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 8f04fb4d9..f93e9164d 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3091,6 +3091,33 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = InventoryItemRole.objects.all() + filterset = InventoryItemRoleFilterSet + + @classmethod + def setUpTestData(cls): + + roles = ( + InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1', color='ff0000'), + InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2', color='00ff00'), + InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3', color='0000ff'), + ) + InventoryItemRole.objects.bulk_create(roles) + + def test_name(self): + params = {'name': ['Inventory Item Role 1', 'Inventory Item Role 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['inventory-item-role-1', 'inventory-item-role-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_color(self): + params = {'color': ['ff0000', '00ff00']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualChassis.objects.all() filterset = VirtualChassisFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 12216a8ac..3ac7b9c72 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1408,7 +1408,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'name': 'Devie Role X', + 'name': 'Device Role X', 'slug': 'device-role-x', 'color': 'c0c0c0', 'vm_role': False, @@ -2375,6 +2375,41 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): ) +class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = InventoryItemRole + + @classmethod + def setUpTestData(cls): + + InventoryItemRole.objects.bulk_create([ + InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), + InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), + InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Inventory Item Role X', + 'slug': 'inventory-item-role-x', + 'color': 'c0c0c0', + 'description': 'New inventory item role', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,slug,color", + "Inventory Item Role 4,inventory-item-role-4,ff0000", + "Inventory Item Role 5,inventory-item-role-5,00ff00", + "Inventory Item Role 6,inventory-item-role-6,0000ff", + ) + + cls.bulk_edit_data = { + 'color': '00ff00', + 'description': 'New description', + } + + # TODO: Change base class to PrimaryObjectViewTestCase # Blocked by lack of common creation view for cables (termination A must be initialized) class CableTestCase( diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 8ec30c0cc..d45ce7577 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -425,6 +425,17 @@ urlpatterns = [ path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}), path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'), + # Device 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'), + path('inventory-item-roles/edit/', views.InventoryItemRoleBulkEditView.as_view(), name='inventoryitemrole_bulk_edit'), + path('inventory-item-roles/delete/', views.InventoryItemRoleBulkDeleteView.as_view(), name='inventoryitemrole_bulk_delete'), + 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}), + # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5aff57a4e..8e3d35b3e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2428,6 +2428,59 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView): template_name = 'dcim/inventoryitem_bulk_delete.html' +# +# Inventory item roles +# + +class InventoryItemRoleListView(generic.ObjectListView): + queryset = InventoryItemRole.objects.annotate( + inventoryitem_count=count_related(InventoryItem, 'role'), + ) + filterset = filtersets.InventoryItemRoleFilterSet + filterset_form = forms.InventoryItemRoleFilterForm + table = tables.InventoryItemRoleTable + + +class InventoryItemRoleView(generic.ObjectView): + queryset = InventoryItemRole.objects.all() + + def get_extra_context(self, request, instance): + return { + 'inventoryitem_count': InventoryItem.objects.filter(role=instance).count(), + } + + +class InventoryItemRoleEditView(generic.ObjectEditView): + queryset = InventoryItemRole.objects.all() + model_form = forms.InventoryItemRoleForm + + +class InventoryItemRoleDeleteView(generic.ObjectDeleteView): + queryset = InventoryItemRole.objects.all() + + +class InventoryItemRoleBulkImportView(generic.BulkImportView): + queryset = InventoryItemRole.objects.all() + model_form = forms.InventoryItemRoleCSVForm + table = tables.InventoryItemRoleTable + + +class InventoryItemRoleBulkEditView(generic.BulkEditView): + queryset = InventoryItemRole.objects.annotate( + inventoryitem_count=count_related(InventoryItem, 'role'), + ) + filterset = filtersets.InventoryItemRoleFilterSet + table = tables.InventoryItemRoleTable + form = forms.InventoryItemRoleBulkEditForm + + +class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView): + queryset = InventoryItemRole.objects.annotate( + inventoryitem_count=count_related(InventoryItem, 'role'), + ) + table = tables.InventoryItemRoleTable + + # # Bulk Device component creation # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 52359dcc6..3b5076273 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -166,6 +166,7 @@ DEVICES_MENU = Menu( get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']), get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']), get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']), + get_model_item('dcim', 'inventoryitemrole', 'Inventory Item Roles'), ), ), ), diff --git a/netbox/templates/dcim/inventoryitemrole.html b/netbox/templates/dcim/inventoryitemrole.html new file mode 100644 index 000000000..f750d74ce --- /dev/null +++ b/netbox/templates/dcim/inventoryitemrole.html @@ -0,0 +1,53 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    Inventory Item Role
    +
    +
    Permitted VIDs{{ object.min_vid }} - {{ object.max_vid }}
    VLANs From a03ae295f6415135bf025c429c5e5ac2c9bd4b40 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Dec 2021 11:39:56 -0500 Subject: [PATCH 038/271] Update release notes --- docs/release-notes/version-3.2.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 693335ae9..b4a209007 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -41,6 +41,7 @@ FIELD_CHOICES = { ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation +* [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group ### Other Changes @@ -52,6 +53,25 @@ FIELD_CHOICES = { ### REST API Changes +* Added the following endpoints for modules & module types: + * `/api/dcim/modules/` + * `/api/dcim/module-bays/` + * `/api/dcim/module-bay-templates/` + * `/api/dcim/module-types/` +* dcim.ConsolePort + * Added `module` field +* dcim.ConsoleServerPort + * Added `module` field +* dcim.FrontPort + * Added `module` field +* dcim.Interface + * Added `module` field +* dcim.PowerPort + * Added `module` field +* dcim.PowerOutlet + * Added `module` field +* dcim.RearPort + * Added `module` field * dcim.Site * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields * ipam.VLANGroup From bffd22038b005fc4bce8cafb7ac543ab246dd7de Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Dec 2021 13:50:01 -0500 Subject: [PATCH 039/271] Closes #7681: Add service_id field for provider networks --- docs/models/circuits/providernetwork.md | 2 +- docs/release-notes/version-3.2.md | 3 +++ netbox/circuits/api/serializers.py | 4 ++-- netbox/circuits/filtersets.py | 3 ++- netbox/circuits/forms/bulk_edit.py | 8 ++++++-- netbox/circuits/forms/bulk_import.py | 2 +- netbox/circuits/forms/filtersets.py | 4 ++++ netbox/circuits/forms/models.py | 4 ++-- .../migrations/0032_provider_service_id.py | 16 ++++++++++++++++ netbox/circuits/models/providers.py | 6 +++++- netbox/circuits/tables.py | 4 ++-- netbox/templates/circuits/providernetwork.html | 4 ++++ 12 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 netbox/circuits/migrations/0032_provider_service_id.py diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md index f5a428e96..42c46e13c 100644 --- a/docs/models/circuits/providernetwork.md +++ b/docs/models/circuits/providernetwork.md @@ -2,4 +2,4 @@ This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity. -Each provider network must be assigned to a provider. A circuit may terminate to either a provider network or to a site. +Each provider network must be assigned to a provider, and may optionally be assigned an arbitrary service ID. A circuit may terminate to either a provider network or to a site. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b4a209007..2944b5ee9 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -41,6 +41,7 @@ FIELD_CHOICES = { ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation +* [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group @@ -58,6 +59,8 @@ FIELD_CHOICES = { * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` * `/api/dcim/module-types/` +* circuits.ProviderNetwork + * Added `service_id` field * dcim.ConsolePort * Added `module` field * dcim.ConsoleServerPort diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 470a0b030..7a827d547 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -37,8 +37,8 @@ class ProviderNetworkSerializer(PrimaryModelSerializer): class Meta: model = ProviderNetwork fields = [ - 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index fd582dd99..0a90116bd 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -98,13 +98,14 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): class Meta: model = ProviderNetwork - fields = ['id', 'name'] + fields = ['id', 'name', 'service_id'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(name__icontains=value) | + Q(service_id__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 37edd3a62..af6bca91f 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -62,10 +62,14 @@ class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor queryset=Provider.objects.all(), required=False ) - description = forms.CharField( + service_id = forms.CharField( max_length=100, required=False ) + description = forms.CharField( + max_length=200, + required=False + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -73,7 +77,7 @@ class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor class Meta: nullable_fields = [ - 'description', 'comments', + 'service_id', 'description', 'comments', ] diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index af5ec4425..fe1b927e5 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -32,7 +32,7 @@ class ProviderNetworkCSVForm(CustomFieldModelCSVForm): class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'description', 'comments', + 'provider', 'name', 'service_id', 'description', 'comments', ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 0822ff206..68b57e03c 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -64,6 +64,10 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm): label=_('Provider'), fetch_trigger='open' ) + service_id = forms.CharField( + max_length=100, + required=False + ) tag = TagFilterField(model) diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 2ea246fd0..f67114828 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -66,10 +66,10 @@ class ProviderNetworkForm(CustomFieldModelForm): class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'description', 'comments', 'tags', + 'provider', 'name', 'service_id', 'description', 'comments', 'tags', ] fieldsets = ( - ('Provider Network', ('provider', 'name', 'description', 'tags')), + ('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')), ) diff --git a/netbox/circuits/migrations/0032_provider_service_id.py b/netbox/circuits/migrations/0032_provider_service_id.py new file mode 100644 index 000000000..91410bd96 --- /dev/null +++ b/netbox/circuits/migrations/0032_provider_service_id.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0004_rename_cable_peer'), + ] + + operations = [ + migrations.AddField( + model_name='providernetwork', + name='service_id', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index b3a6902f9..153e241a7 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -5,7 +5,6 @@ from django.urls import reverse from dcim.fields import ASNField from extras.utils import extras_features from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet __all__ = ( 'ProviderNetwork', @@ -87,6 +86,11 @@ class ProviderNetwork(PrimaryModel): on_delete=models.PROTECT, related_name='networks' ) + service_id = models.CharField( + max_length=100, + blank=True, + verbose_name='Service ID' + ) description = models.CharField( max_length=200, blank=True diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 86a55eba5..32c40f269 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -69,8 +69,8 @@ class ProviderNetworkTable(BaseTable): class Meta(BaseTable.Meta): model = ProviderNetwork - fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags') - default_columns = ('pk', 'name', 'provider', 'description') + fields = ('pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'tags') + default_columns = ('pk', 'name', 'provider', 'service_id', 'description') # diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 970cd4a54..d1c513f98 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -28,6 +28,10 @@ Name {{ object.name }}
    Service ID{{ object.service_id|placeholder }}
    Description {{ object.description|placeholder }}
    + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Inventory Items + {{ inventoryitem_count }} +
    +
    +
    + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} From 6e9afccfd79363d9862c4a99aff0e477c3b27e45 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 10:45:33 -0500 Subject: [PATCH 042/271] #8037: Add role field to InventoryItem --- docs/models/dcim/inventoryitem.md | 2 +- netbox/dcim/api/serializers.py | 3 +- netbox/dcim/filtersets.py | 10 +++++ netbox/dcim/forms/bulk_create.py | 6 +-- netbox/dcim/forms/bulk_edit.py | 8 +++- netbox/dcim/forms/bulk_import.py | 8 +++- netbox/dcim/forms/filtersets.py | 6 +++ netbox/dcim/forms/models.py | 8 +++- netbox/dcim/forms/object_create.py | 14 +++--- netbox/dcim/models/device_components.py | 16 +++---- netbox/dcim/tables/devices.py | 56 ++++++++++++------------ netbox/dcim/tests/test_api.py | 15 +++++-- netbox/dcim/tests/test_filtersets.py | 21 +++++++-- netbox/dcim/tests/test_views.py | 15 +++++-- netbox/dcim/views.py | 4 +- netbox/templates/dcim/inventoryitem.html | 14 ++++-- 16 files changed, 141 insertions(+), 65 deletions(-) diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 237bad92c..f98371833 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -2,6 +2,6 @@ Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes. -Each inventory item can be assigned a manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox). +Each inventory item can be assigned a functional role, manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox). Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index fe8487411..5e07ea3fd 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -811,12 +811,13 @@ class InventoryItemSerializer(PrimaryModelSerializer): device = NestedDeviceSerializer() parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = InventoryItem fields = [ - 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', + 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5f4840fde..01c0a278d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1284,6 +1284,16 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=InventoryItemRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) serial = django_filters.CharFilter( lookup_expr='iexact' ) diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 8eae46111..e78e0ee19 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -107,11 +107,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): class InventoryItemBulkCreateForm( - form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), + form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), DeviceBulkAddComponentForm ): model = InventoryItem field_order = ( - 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', - 'tags', + 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8fc8835cb..93a90a1cb 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1172,7 +1172,7 @@ class DeviceBayBulkEditForm( class InventoryItemBulkEditForm( - form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), + form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), AddRemoveTagsForm, CustomFieldModelBulkEditForm ): @@ -1180,13 +1180,17 @@ class InventoryItemBulkEditForm( queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput() ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) class Meta: - nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] + nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description'] # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 40838c60c..1297fc980 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -772,6 +772,11 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): queryset=Device.objects.all(), to_field_name='name' ) + role = CSVModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', @@ -787,7 +792,8 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): class Meta: model = InventoryItem fields = ( - 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', + 'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index ae58e1d2f..c12891dc3 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1100,6 +1100,12 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] + role_id = DynamicModelMultipleChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False, + label=_('Role'), + fetch_trigger='open' + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index b5b15e731..2be571f71 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1368,6 +1368,10 @@ class InventoryItemForm(CustomFieldModelForm): 'device_id': '$device' } ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -1380,8 +1384,8 @@ class InventoryItemForm(CustomFieldModelForm): class Meta: model = InventoryItem fields = [ - 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'tags', + 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'description', 'tags', ] diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 681a17a5a..9e208300b 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -652,10 +652,6 @@ class DeviceBayCreateForm(ComponentCreateForm): class InventoryItemCreateForm(ComponentCreateForm): model = InventoryItem - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, @@ -663,6 +659,14 @@ class InventoryItemCreateForm(ComponentCreateForm): 'device_id': '$device' } ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) part_id = forms.CharField( max_length=50, required=False, @@ -677,6 +681,6 @@ class InventoryItemCreateForm(ComponentCreateForm): required=False, ) field_order = ( - 'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'device', 'parent', 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5329c9e01..cb38d8683 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -994,6 +994,13 @@ class InventoryItem(MPTTModel, ComponentModel): null=True, db_index=True ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_items', + blank=True, + null=True + ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -1007,13 +1014,6 @@ class InventoryItem(MPTTModel, ComponentModel): blank=True, help_text='Manufacturer-assigned part identifier' ) - role = models.ForeignKey( - to='dcim.InventoryItemRole', - on_delete=models.PROTECT, - related_name='inventory_items', - blank=True, - null=True - ) serial = models.CharField( max_length=50, verbose_name='Serial number', @@ -1034,7 +1034,7 @@ class InventoryItem(MPTTModel, ComponentModel): objects = TreeManager() - clone_fields = ['device', 'parent', 'manufacturer', 'part_id', 'role'] + clone_fields = ['device', 'parent', 'role', 'manufacturer', 'part_id'] class Meta: ordering = ('device__id', 'parent__id', '_name') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f889d52ec..9472be541 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -774,6 +774,9 @@ class InventoryItemTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + role = tables.Column( + linkify=True + ) manufacturer = tables.Column( linkify=True ) @@ -786,10 +789,33 @@ class InventoryItemTable(DeviceComponentTable): class Meta(BaseTable.Meta): model = InventoryItem fields = ( - 'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'discovered', 'tags', + 'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'description', 'discovered', 'tags', + ) + default_columns = ('pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag') + + +class DeviceInventoryItemTable(InventoryItemTable): + name = tables.TemplateColumn( + template_code='' + '{{ value }}', + order_by=Accessor('_name'), + attrs={'td': {'class': 'text-nowrap'}} + ) + actions = ButtonsColumn( + model=InventoryItem, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = InventoryItem + fields = ( + 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'discovered', 'tags', 'actions', + ) + default_columns = ( + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'actions', ) - default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') class InventoryItemRoleTable(BaseTable): @@ -816,30 +842,6 @@ class InventoryItemRoleTable(BaseTable): default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions') -class DeviceInventoryItemTable(InventoryItemTable): - name = tables.TemplateColumn( - template_code='' - '{{ value }}', - order_by=Accessor('_name'), - attrs={'td': {'class': 'text-nowrap'}} - ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = InventoryItem - fields = ( - 'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'tags', 'actions', - ) - default_columns = ( - 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'actions', - ) - - # # Virtual chassis # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a6c7760f4..e6ea10499 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1626,24 +1626,33 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site) - InventoryItem.objects.create(device=device, name='Inventory Item 1', manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 2', manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 3', manufacturer=manufacturer) + roles = ( + InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), + InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), + ) + InventoryItemRole.objects.bulk_create(roles) + + InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) + InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) + InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) cls.create_data = [ { 'device': device.pk, 'name': 'Inventory Item 4', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, }, { 'device': device.pk, 'name': 'Inventory Item 5', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, }, { 'device': device.pk, 'name': 'Inventory Item 6', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f93e9164d..a808aeda2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2949,7 +2949,6 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -2998,10 +2997,17 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + roles = ( + InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), + InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), + InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'), + ) + InventoryItemRole.objects.bulk_create(roles) + inventory_items = ( - InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), - InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), - InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), + InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), + InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), + InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), ) for i in inventory_items: i.save() @@ -3077,6 +3083,13 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_items[0].pk, parent_items[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_role(self): + roles = InventoryItemRole.objects.all()[:2] + params = {'role_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'role': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_manufacturer(self): manufacturers = Manufacturer.objects.all()[:2] params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 3ac7b9c72..8f077df92 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2331,14 +2331,21 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): device = create_test_device('Device 1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') - InventoryItem.objects.create(device=device, name='Inventory Item 1') - InventoryItem.objects.create(device=device, name='Inventory Item 2') - InventoryItem.objects.create(device=device, name='Inventory Item 3') + roles = ( + InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), + InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), + ) + InventoryItemRole.objects.bulk_create(roles) + + InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) + InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) + InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, 'name': 'Inventory Item X', 'parent': None, @@ -2353,6 +2360,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, 'name_pattern': 'Inventory Item [4-6]', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, 'parent': None, 'discovered': False, @@ -2363,6 +2371,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.bulk_edit_data = { + 'role': roles[1].pk, 'part_id': '123456', 'description': 'New description', } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8e3d35b3e..bfa2fecae 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2412,7 +2412,7 @@ class InventoryItemBulkImportView(generic.BulkImportView): class InventoryItemBulkEditView(generic.BulkEditView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable form = forms.InventoryItemBulkEditForm @@ -2423,7 +2423,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView): class InventoryItemBulkDeleteView(generic.BulkDeleteView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 36ba0469f..7de303656 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -13,9 +13,7 @@
    -
    - Inventory Item -
    +
    Inventory Item
    @@ -42,6 +40,16 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endif %} +
    Label {{ object.label|placeholder }}
    Role + {% if object.role %} + {{ object.role }} + {% else %} + + {% endif %} +
    Manufacturer From a748083f2606b28baf9ed2cf2a9c4e48d92480bd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 10:52:04 -0500 Subject: [PATCH 043/271] Changelog for #3087 --- docs/release-notes/version-3.2.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index f35100c72..608a436a1 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,10 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. +#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) + +A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. + #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. @@ -55,7 +59,8 @@ FIELD_CHOICES = { ### REST API Changes -* Added the following endpoints for modules & module types: +* Added the following endpoints: + * `/api/dcim/inventory-item-roles/` * `/api/dcim/modules/` * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` @@ -70,6 +75,8 @@ FIELD_CHOICES = { * Added `module` field * dcim.Interface * Added `module` field +* dcim.InventoryItem + * Added `role` field * dcim.PowerPort * Added `module` field * dcim.PowerOutlet From 99d5013de30330a2c943ddb0e3030db0e49f6258 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 13:26:17 -0500 Subject: [PATCH 044/271] Initial work on #7846 --- netbox/dcim/api/serializers.py | 19 +++++++++++++-- netbox/dcim/constants.py | 21 ++++++++++++++--- netbox/dcim/filtersets.py | 2 ++ netbox/dcim/forms/models.py | 20 +++++++++++++--- netbox/dcim/forms/object_create.py | 16 ++++++++++--- .../0147_inventoryitem_component.py | 23 +++++++++++++++++++ netbox/dcim/models/device_components.py | 22 ++++++++++++++++++ netbox/dcim/tables/devices.py | 15 ++++++++---- netbox/dcim/tests/test_api.py | 19 ++++++++++++--- netbox/dcim/tests/test_filtersets.py | 16 ++++++++++--- netbox/templates/dcim/inventoryitem.html | 10 ++++++++ 11 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 netbox/dcim/migrations/0147_inventoryitem_component.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5e07ea3fd..30f451e84 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -810,17 +810,32 @@ class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) - manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = InventoryItem fields = [ 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', - 'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', + 'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags', + 'custom_fields', 'created', 'last_updated', '_depth', ] + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_component(self, obj): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.component, context=context).data + # # Device component roles diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 2136f06aa..00126ebf8 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -50,16 +50,31 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES # -# PowerFeeds +# Power feeds # POWERFEED_VOLTAGE_DEFAULT = 120 - POWERFEED_AMPERAGE_DEFAULT = 20 - POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage +# +# Device components +# + +MODULAR_COMPONENT_MODELS = Q( + app_label='dcim', + model__in=( + 'consoleport', + 'consoleserverport', + 'frontport', + 'interface', + 'poweroutlet', + 'powerport', + 'rearport', + )) + + # # Cabling and connections # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 01c0a278d..14a2ae3ee 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1294,6 +1294,8 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): to_field_name='slug', label='Role (slug)', ) + component_type = ContentTypeFilter() + component_id = MultiValueNumberFilter() serial = django_filters.CharFilter( lookup_expr='iexact' ) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2be571f71..b193f76d5 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -12,8 +12,8 @@ from extras.models import Tag from ipam.models import IPAddress, VLAN, VLANGroup, ASN from tenancy.forms import TenancyForm from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, + APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup @@ -1376,6 +1376,15 @@ class InventoryItemForm(CustomFieldModelForm): queryset=Manufacturer.objects.all(), required=False ) + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_MODELS, + required=False, + widget=StaticSelect + ) + component_id = forms.IntegerField( + required=False + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1385,8 +1394,13 @@ class InventoryItemForm(CustomFieldModelForm): model = InventoryItem fields = [ 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'tags', + 'description', 'component_type', 'component_id', 'tags', ] + fieldsets = ( + ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), + ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), + ('Component', ('component_type', 'component_id')), + ) # diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 9e208300b..03c488865 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from dcim.choices import * from dcim.constants import * @@ -7,8 +8,8 @@ from extras.forms import CustomFieldModelForm, CustomFieldsMixin from extras.models import Tag from ipam.models import VLAN from utilities.forms import ( - add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, StaticSelect, + add_blank_choice, BootstrapMixin, ColorField, ContentTypeChoiceField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, StaticSelect, ) from wireless.choices import * from .common import InterfaceCommonForm @@ -680,7 +681,16 @@ class InventoryItemCreateForm(ComponentCreateForm): max_length=50, required=False, ) + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_MODELS, + required=False, + widget=StaticSelect + ) + component_id = forms.IntegerField( + required=False + ) field_order = ( 'device', 'parent', 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'tags', + 'description', 'component_type', 'component_id', 'tags', ) diff --git a/netbox/dcim/migrations/0147_inventoryitem_component.py b/netbox/dcim/migrations/0147_inventoryitem_component.py new file mode 100644 index 000000000..36085c35d --- /dev/null +++ b/netbox/dcim/migrations/0147_inventoryitem_component.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0146_inventoryitemrole'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryitem', + name='component_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='component_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index cb38d8683..cdfaa7c89 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -97,6 +97,12 @@ class ModularComponentModel(ComponentModel): blank=True, null=True ) + inventory_items = GenericRelation( + to='dcim.InventoryItem', + content_type_field='component_type', + object_id_field='component_id', + related_name='%(class)ss', + ) class Meta: abstract = True @@ -994,6 +1000,22 @@ class InventoryItem(MPTTModel, ComponentModel): null=True, db_index=True ) + component_type = models.ForeignKey( + to=ContentType, + limit_choices_to=MODULAR_COMPONENT_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + component_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + component = GenericForeignKey( + ct_field='component_type', + fk_field='component_id' + ) role = models.ForeignKey( to='dcim.InventoryItemRole', on_delete=models.PROTECT, diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 9472be541..4eda4a937 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -780,6 +780,9 @@ class InventoryItemTable(DeviceComponentTable): manufacturer = tables.Column( linkify=True ) + component = tables.Column( + linkify=True + ) discovered = BooleanColumn() tags = TagColumn( url_name='dcim:inventoryitem_list' @@ -790,9 +793,11 @@ class InventoryItemTable(DeviceComponentTable): model = InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'discovered', 'tags', + 'component', 'description', 'discovered', 'tags', + ) + default_columns = ( + 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', ) - default_columns = ('pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag') class DeviceInventoryItemTable(InventoryItemTable): @@ -810,11 +815,11 @@ class DeviceInventoryItemTable(InventoryItemTable): class Meta(BaseTable.Meta): model = InventoryItem fields = ( - 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'discovered', 'tags', 'actions', + 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', + 'description', 'discovered', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'actions', + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions', ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index e6ea10499..b3c41e277 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1632,9 +1632,16 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): ) InventoryItemRole.objects.bulk_create(roles) - InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) - InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) + interfaces = ( + Interface(device=device, name='Interface 1'), + Interface(device=device, name='Interface 2'), + Interface(device=device, name='Interface 3'), + ) + Interface.objects.bulk_create(interfaces) + + InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer, component=interfaces[0]) + InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer, component=interfaces[1]) + InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer, component=interfaces[2]) cls.create_data = [ { @@ -1642,18 +1649,24 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): 'name': 'Inventory Item 4', 'role': roles[1].pk, 'manufacturer': manufacturer.pk, + 'component_type': 'dcim.interface', + 'component_id': interfaces[0].pk, }, { 'device': device.pk, 'name': 'Inventory Item 5', 'role': roles[1].pk, 'manufacturer': manufacturer.pk, + 'component_type': 'dcim.interface', + 'component_id': interfaces[1].pk, }, { 'device': device.pk, 'name': 'Inventory Item 6', 'role': roles[1].pk, 'manufacturer': manufacturer.pk, + 'component_type': 'dcim.interface', + 'component_id': interfaces[2].pk, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index a808aeda2..f53705336 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3004,10 +3004,16 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): ) InventoryItemRole.objects.bulk_create(roles) + components = ( + Interface.objects.create(device=devices[0], name='Interface 1'), + ConsolePort.objects.create(device=devices[1], name='Console Port 1'), + ConsoleServerPort.objects.create(device=devices[2], name='Console Server Port 1'), + ) + inventory_items = ( - InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), - InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), - InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), + InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First', component=components[0]), + InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second', component=components[1]), + InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third', component=components[2]), ) for i in inventory_items: i.save() @@ -3103,6 +3109,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'serial': 'abc'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_component_type(self): + params = {'component_type': 'dcim.interface'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = InventoryItemRole.objects.all() diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 7de303656..0e30c5c8c 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -50,6 +50,16 @@ {% endif %}
    Component + {% if object.component %} + {{ object.component }} + {% else %} + + {% endif %} +
    Manufacturer From a237c01b4b7e75664c180155e8e6689f163c55d3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 21:04:29 -0500 Subject: [PATCH 045/271] Refactor ComponentCreateView to use separate forms for names/labels and model creation --- netbox/dcim/forms/bulk_create.py | 4 +- netbox/dcim/forms/object_create.py | 588 +------------------ netbox/dcim/tests/test_forms.py | 30 +- netbox/dcim/views.py | 19 - netbox/netbox/views/generic/object_views.py | 35 +- netbox/templates/dcim/component_create.html | 7 + netbox/virtualization/forms/object_create.py | 72 +-- netbox/virtualization/views.py | 2 +- 8 files changed, 49 insertions(+), 708 deletions(-) create mode 100644 netbox/templates/dcim/component_create.html diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index e78e0ee19..02c8feb4b 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -4,7 +4,7 @@ from dcim.models import * from extras.forms import CustomFieldsMixin from extras.models import Tag from utilities.forms import DynamicModelMultipleChoiceField, form_from_model -from .object_create import ComponentForm +from .object_create import ComponentCreateForm __all__ = ( 'ConsolePortBulkCreateForm', @@ -24,7 +24,7 @@ __all__ = ( # Device components # -class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm): +class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 03c488865..8b8c00c6d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,44 +1,19 @@ from django import forms -from django.contrib.contenttypes.models import ContentType -from dcim.choices import * -from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelForm, CustomFieldsMixin +from extras.forms import CustomFieldModelForm from extras.models import Tag -from ipam.models import VLAN from utilities.forms import ( - add_blank_choice, BootstrapMixin, ColorField, ContentTypeChoiceField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, StaticSelect, + BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, ) -from wireless.choices import * -from .common import InterfaceCommonForm __all__ = ( - 'ConsolePortCreateForm', - 'ConsolePortTemplateCreateForm', - 'ConsoleServerPortCreateForm', - 'ConsoleServerPortTemplateCreateForm', - 'DeviceBayCreateForm', - 'DeviceBayTemplateCreateForm', - 'FrontPortCreateForm', - 'FrontPortTemplateCreateForm', - 'InterfaceCreateForm', - 'InterfaceTemplateCreateForm', - 'InventoryItemCreateForm', - 'ModuleBayCreateForm', - 'ModuleBayTemplateCreateForm', - 'PowerOutletCreateForm', - 'PowerOutletTemplateCreateForm', - 'PowerPortCreateForm', - 'PowerPortTemplateCreateForm', - 'RearPortCreateForm', - 'RearPortTemplateCreateForm', + 'ComponentCreateForm', 'VirtualChassisCreateForm', ) -class ComponentForm(BootstrapMixin, forms.Form): +class ComponentCreateForm(BootstrapMixin, forms.Form): """ Subclass this form when facilitating the creation of one or more device component or component templates based on a name pattern. @@ -139,558 +114,3 @@ class VirtualChassisCreateForm(CustomFieldModelForm): member.save() return instance - - -# -# Component templates -# - -class ComponentTemplateCreateForm(ComponentForm): - """ - Base form for the creation of device component templates (subclassed from ComponentTemplateModel). - """ - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - initial_params={ - 'device_types': 'device_type', - 'module_types': 'module_type', - } - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - required=False, - query_params={ - 'manufacturer_id': '$manufacturer' - } - ) - description = forms.CharField( - required=False - ) - - -class ModularComponentTemplateCreateForm(ComponentTemplateCreateForm): - module_type = DynamicModelChoiceField( - queryset=ModuleType.objects.all(), - required=False, - query_params={ - 'manufacturer_id': '$manufacturer' - } - ) - - -class ConsolePortTemplateCreateForm(ModularComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ( - 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description', - ) - - -class ConsoleServerPortTemplateCreateForm(ModularComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ( - 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description', - ) - - -class PowerPortTemplateCreateForm(ModularComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum power draw (watts)" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated power draw (watts)" - ) - field_order = ( - 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', - 'allocated_draw', 'description', - ) - - -class PowerOutletTemplateCreateForm(ModularComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False - ) - power_port = DynamicModelChoiceField( - queryset=PowerPortTemplate.objects.all(), - required=False, - query_params={ - 'devicetype_id': '$device_type', - 'moduletype_id': '$module_type', - } - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False, - widget=StaticSelect() - ) - field_order = ( - 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', - 'description', - ) - - -class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect() - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only' - ) - field_order = ( - 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', - 'description', - ) - - -class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect() - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', - 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device_type.frontporttemplates.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPortTemplate.objects.filter(device_type=device_type) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -class RearPortTemplateCreateForm(ModularComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - initial=1, - help_text='The number of front ports which may be mapped to each rear port' - ) - field_order = ( - 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', - 'description', - ) - - -class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): - # TODO: Support patterned position assignment - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') - - -class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') - - -# -# Device components -# - -class ComponentCreateForm(CustomFieldsMixin, ComponentForm): - """ - Base form for the creation of device components (models subclassed from ComponentModel). - """ - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - description = forms.CharField( - max_length=200, - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - -class ConsolePortCreateForm(ComponentCreateForm): - model = ConsolePort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class ConsoleServerPortCreateForm(ComponentCreateForm): - model = ConsoleServerPort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class PowerPortCreateForm(ComponentCreateForm): - model = PowerPort - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False, - widget=StaticSelect() - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum draw in watts" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated draw in watts" - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', - 'description', 'tags', - ) - - -class PowerOutletCreateForm(ComponentCreateForm): - model = PowerOutlet - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False, - widget=StaticSelect() - ) - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', - 'tags', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port queryset to PowerPorts which belong to the parent Device - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) - - -class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): - model = Interface - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect(), - ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - parent = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) - bridge = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) - lag = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - 'type': 'lag', - }, - label='LAG' - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - wwn = forms.CharField( - required=False, - label='WWN' - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only', - help_text='This interface is used only for out-of-band management' - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect() - ) - rf_role = forms.ChoiceField( - choices=add_blank_choice(WirelessRoleChoices), - required=False, - widget=StaticSelect(), - label='Wireless role' - ) - rf_channel = forms.ChoiceField( - choices=add_blank_choice(WirelessChannelChoices), - required=False, - widget=StaticSelect(), - label='Wireless channel' - ) - rf_channel_frequency = forms.DecimalField( - required=False, - label='Channel frequency (MHz)' - ) - rf_channel_width = forms.DecimalField( - required=False, - label='Channel width (MHz)' - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Untagged VLAN' - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Tagged VLANs' - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', - 'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit VLAN choices by device - device_id = self.initial.get('device') or self.data.get('device') - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) - - -class FrontPortCreateForm(ComponentCreateForm): - model = FrontPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description', - 'tags', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available - # mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device.frontports.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPort.objects.filter(device=device) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -class RearPortCreateForm(ComponentCreateForm): - model = RearPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - initial=1, - help_text='The number of front ports which may be mapped to each rear port' - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description', - 'tags', - ) - - -class ModuleBayCreateForm(ComponentCreateForm): - model = ModuleBay - field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') - - -class DeviceBayCreateForm(ComponentCreateForm): - model = DeviceBay - field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') - - -class InventoryItemCreateForm(ComponentCreateForm): - model = InventoryItem - parent = DynamicModelChoiceField( - queryset=InventoryItem.objects.all(), - required=False, - query_params={ - 'device_id': '$device' - } - ) - role = DynamicModelChoiceField( - queryset=InventoryItemRole.objects.all(), - required=False - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - part_id = forms.CharField( - max_length=50, - required=False, - label='Part ID' - ) - serial = forms.CharField( - max_length=50, - required=False, - ) - asset_tag = forms.CharField( - max_length=50, - required=False, - ) - component_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=MODULAR_COMPONENT_MODELS, - required=False, - widget=StaticSelect - ) - component_id = forms.IntegerField( - required=False - ) - field_order = ( - 'device', 'parent', 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'component_type', 'component_id', 'tags', - ) diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 3b2a9eff0..4c5de1284 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -118,41 +118,27 @@ class DeviceTestCase(TestCase): class LabelTestCase(TestCase): - @classmethod - def setUpTestData(cls): - site = Site.objects.create(name='Site 2', slug='site-2') - manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2') - cls.device_type = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1 - ) - device_role = DeviceRole.objects.create( - name='Device Role 2', slug='device-role-2', color='ffff00' - ) - cls.device = Device.objects.create( - name='Device 2', device_type=cls.device_type, device_role=device_role, site=site - ) - def test_interface_label_count_valid(self): - """Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm""" + """ + Test that generating an equal number of names and labels passes form validation. + """ interface_data = { - 'device': self.device.pk, 'name_pattern': 'eth[0-9]', 'label_pattern': 'Interface[0-9]', - 'type': InterfaceTypeChoices.TYPE_100ME_FIXED, } - form = InterfaceCreateForm(interface_data) + form = ComponentCreateForm(interface_data) self.assertTrue(form.is_valid()) def test_interface_label_count_mismatch(self): - """Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm""" + """ + Check that attempting to generate a differing number of names and labels results in a validation error. + """ bad_interface_data = { - 'device': self.device.pk, 'name_pattern': 'eth[0-9]', 'label_pattern': 'Interface[0-1]', - 'type': InterfaceTypeChoices.TYPE_100ME_FIXED, } - form = InterfaceCreateForm(bad_interface_data) + form = ComponentCreateForm(bad_interface_data) self.assertFalse(form.is_valid()) self.assertIn('label_pattern', form.errors) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bfa2fecae..1616b95e9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1054,7 +1054,6 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView): class ConsolePortTemplateCreateView(generic.ComponentCreateView): queryset = ConsolePortTemplate.objects.all() - form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm @@ -1088,7 +1087,6 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView): class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView): queryset = ConsoleServerPortTemplate.objects.all() - form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm @@ -1122,7 +1120,6 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerPortTemplateCreateView(generic.ComponentCreateView): queryset = PowerPortTemplate.objects.all() - form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm @@ -1156,7 +1153,6 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerOutletTemplateCreateView(generic.ComponentCreateView): queryset = PowerOutletTemplate.objects.all() - form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm @@ -1190,7 +1186,6 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView): class InterfaceTemplateCreateView(generic.ComponentCreateView): queryset = InterfaceTemplate.objects.all() - form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm @@ -1224,7 +1219,6 @@ class InterfaceTemplateBulkDeleteView(generic.BulkDeleteView): class FrontPortTemplateCreateView(generic.ComponentCreateView): queryset = FrontPortTemplate.objects.all() - form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm @@ -1258,7 +1252,6 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView): class RearPortTemplateCreateView(generic.ComponentCreateView): queryset = RearPortTemplate.objects.all() - form = forms.RearPortTemplateCreateForm model_form = forms.RearPortTemplateForm @@ -1292,7 +1285,6 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView): class ModuleBayTemplateCreateView(generic.ComponentCreateView): queryset = ModuleBayTemplate.objects.all() - form = forms.ModuleBayTemplateCreateForm model_form = forms.ModuleBayTemplateForm @@ -1326,7 +1318,6 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView): class DeviceBayTemplateCreateView(generic.ComponentCreateView): queryset = DeviceBayTemplate.objects.all() - form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm @@ -1741,7 +1732,6 @@ class ConsolePortView(generic.ObjectView): class ConsolePortCreateView(generic.ComponentCreateView): queryset = ConsolePort.objects.all() - form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm @@ -1800,7 +1790,6 @@ class ConsoleServerPortView(generic.ObjectView): class ConsoleServerPortCreateView(generic.ComponentCreateView): queryset = ConsoleServerPort.objects.all() - form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm @@ -1859,7 +1848,6 @@ class PowerPortView(generic.ObjectView): class PowerPortCreateView(generic.ComponentCreateView): queryset = PowerPort.objects.all() - form = forms.PowerPortCreateForm model_form = forms.PowerPortForm @@ -1918,7 +1906,6 @@ class PowerOutletView(generic.ObjectView): class PowerOutletCreateView(generic.ComponentCreateView): queryset = PowerOutlet.objects.all() - form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm @@ -2012,7 +1999,6 @@ class InterfaceView(generic.ObjectView): class InterfaceCreateView(generic.ComponentCreateView): queryset = Interface.objects.all() - form = forms.InterfaceCreateForm model_form = forms.InterfaceForm template_name = 'dcim/interface_create.html' @@ -2098,7 +2084,6 @@ class FrontPortView(generic.ObjectView): class FrontPortCreateView(generic.ComponentCreateView): queryset = FrontPort.objects.all() - form = forms.FrontPortCreateForm model_form = forms.FrontPortForm @@ -2157,7 +2142,6 @@ class RearPortView(generic.ObjectView): class RearPortCreateView(generic.ComponentCreateView): queryset = RearPort.objects.all() - form = forms.RearPortCreateForm model_form = forms.RearPortForm @@ -2216,7 +2200,6 @@ class ModuleBayView(generic.ObjectView): class ModuleBayCreateView(generic.ComponentCreateView): queryset = ModuleBay.objects.all() - form = forms.ModuleBayCreateForm model_form = forms.ModuleBayForm @@ -2271,7 +2254,6 @@ class DeviceBayView(generic.ObjectView): class DeviceBayCreateView(generic.ComponentCreateView): queryset = DeviceBay.objects.all() - form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm @@ -2397,7 +2379,6 @@ class InventoryItemEditView(generic.ObjectEditView): class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() - form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index fed4b2f60..009422038 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError +from django.forms.widgets import HiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.html import escape @@ -14,6 +15,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from django_tables2.export import TableExport +from dcim.forms.object_create import ComponentCreateForm from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror @@ -674,33 +676,45 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Device/VirtualMachine components # -# TODO: Replace with BulkCreateView class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ queryset = None - form = None + form = ComponentCreateForm model_form = None - template_name = 'generic/object_edit.html' + template_name = 'dcim/component_create.html' + patterned_fields = ('name', 'label') def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') - def get(self, request): + def initialize_forms(self, request): + data = request.POST if request.method == 'POST' else None + initial_data = normalize_querydict(request.GET) - form = self.form(initial=request.GET) + form = self.form(data=data, initial=request.GET) + model_form = self.model_form(data=data, initial=initial_data) + + # These fields will be set from the pattern values + for field_name in self.patterned_fields: + model_form.fields[field_name].widget = HiddenInput() + + return form, model_form + + def get(self, request): + form, model_form = self.initialize_forms(request) return render(request, self.template_name, { - 'obj': self.queryset.model(), 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, + 'model_form': model_form, 'return_url': self.get_return_url(request), }) def post(self, request): - logger = logging.getLogger('netbox.views.ComponentCreateView') - form = self.form(request.POST, initial=request.GET) + form, model_form = self.initialize_forms(request) + self.validate_form(request, form) if form.is_valid() and not form.errors: @@ -712,6 +726,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View return render(request, self.template_name, { 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, + 'model_form': model_form, 'return_url': self.get_return_url(request), }) @@ -734,8 +749,8 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View data['name'] = name data['label'] = label - if hasattr(form, 'get_iterative_data'): - data.update(form.get_iterative_data(i)) + # if hasattr(form, 'get_iterative_data'): + # data.update(form.get_iterative_data(i)) component_form = self.model_form(data) diff --git a/netbox/templates/dcim/component_create.html b/netbox/templates/dcim/component_create.html new file mode 100644 index 000000000..367fc2d11 --- /dev/null +++ b/netbox/templates/dcim/component_create.html @@ -0,0 +1,7 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} + {% render_form form %} + {% render_form model_form %} +{% endblock form %} diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index 332334594..f275469fd 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,81 +1,13 @@ from django import forms -from dcim.choices import InterfaceModeChoices -from dcim.forms.common import InterfaceCommonForm -from extras.forms import CustomFieldsMixin -from extras.models import Tag -from ipam.models import VLAN -from utilities.forms import ( - add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, - StaticSelect, -) -from virtualization.models import VMInterface, VirtualMachine +from utilities.forms import BootstrapMixin, ExpandableNameField __all__ = ( 'VMInterfaceCreateForm', ) -class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm): - model = VMInterface - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all() - ) +class VMInterfaceCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - parent = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - query_params={ - 'virtual_machine_id': '$virtual_machine', - } - ) - bridge = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - query_params={ - 'virtual_machine_id': '$virtual_machine', - } - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - description = forms.CharField( - max_length=200, - required=False - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect(), - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - field_order = ( - 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags' - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - - # Limit VLAN choices by virtual machine - self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 8183555bd..742d6d9ea 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -447,11 +447,11 @@ class VMInterfaceView(generic.ObjectView): } -# TODO: This should not use ComponentCreateView class VMInterfaceCreateView(generic.ComponentCreateView): queryset = VMInterface.objects.all() form = forms.VMInterfaceCreateForm model_form = forms.VMInterfaceForm + patterned_fields = ('name',) class VMInterfaceEditView(generic.ObjectEditView): From ba85101d30cde82a5e108f3c47ff2b7a28913694 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 21:25:47 -0500 Subject: [PATCH 046/271] Update component model forms to use DynamicModelChoiceField query_params for related objects --- netbox/dcim/forms/models.py | 112 ++++++++++++-------------- netbox/dcim/views.py | 53 ++++++------ netbox/virtualization/forms/models.py | 24 +++--- 3 files changed, 87 insertions(+), 102 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index b193f76d5..f5988030c 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -957,6 +957,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): widgets = { 'device_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(), + 'type': StaticSelect, } @@ -969,6 +970,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): widgets = { 'device_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(), + 'type': StaticSelect, } @@ -981,10 +983,19 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): widgets = { 'device_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), } class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): + power_port = DynamicModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type', + } + ) + class Meta: model = PowerOutletTemplate fields = [ @@ -993,18 +1004,10 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): widgets = { 'device_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), + 'feed_leg': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType/ModuleType - if self.instance.pk: - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=self.instance.device_type, - module_type=self.instance.module_type - ) - class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1020,6 +1023,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): + rear_port = DynamicModelChoiceField( + queryset=RearPortTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type', + } + ) + class Meta: model = FrontPortTemplate fields = [ @@ -1029,19 +1040,9 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): widgets = { 'device_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(), - 'rear_port': StaticSelect(), + 'type': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit rear_port choices to current DeviceType/ModuleType - if self.instance.pk: - self.fields['rear_port'].queryset = RearPortTemplate.objects.filter( - device_type=self.instance.device_type, - module_type=self.instance.module_type - ) - class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: @@ -1095,6 +1096,7 @@ class ConsolePortForm(CustomFieldModelForm): ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), } @@ -1111,6 +1113,7 @@ class ConsoleServerPortForm(CustomFieldModelForm): ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), } @@ -1128,13 +1131,17 @@ class PowerPortForm(CustomFieldModelForm): ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), } class PowerOutletForm(CustomFieldModelForm): - power_port = forms.ModelChoiceField( + power_port = DynamicModelChoiceField( queryset=PowerPort.objects.all(), - required=False + required=False, + query_params={ + 'device_id': '$device', + } ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -1148,34 +1155,34 @@ class PowerOutletForm(CustomFieldModelForm): ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'feed_leg': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to the local device - if hasattr(self.instance, 'device'): - self.fields['power_port'].queryset = PowerPort.objects.filter( - device=self.instance.device - ) - class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface' + label='Parent interface', + query_params={ + 'device_id': '$device', + } ) bridge = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Bridged interface' + label='Bridged interface', + query_params={ + 'device_id': '$device', + } ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, label='LAG interface', query_params={ + 'device_id': '$device', 'type': 'lag', } ) @@ -1203,6 +1210,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Untagged VLAN', query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', } ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -1211,6 +1219,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Tagged VLANs', query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', } ) tags = DynamicModelMultipleChoiceField( @@ -1241,26 +1250,15 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'rf_channel_width': "Populated by selected channel (if set)", } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - - # Restrict parent/bridge/LAG interface assignment by device/VC - self.fields['parent'].widget.add_query_param('device_id', device.pk) - self.fields['bridge'].widget.add_query_param('device_id', device.pk) - self.fields['lag'].widget.add_query_param('device_id', device.pk) - if device.virtual_chassis and device.virtual_chassis.master: - self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - - # Limit VLAN choices by device - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) - class FrontPortForm(CustomFieldModelForm): + rear_port = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1275,18 +1273,8 @@ class FrontPortForm(CustomFieldModelForm): widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), - 'rear_port': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit RearPort choices to the local device - if hasattr(self.instance, 'device'): - self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter( - device=self.instance.device - ) - class RearPortForm(CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1616b95e9..05416b720 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2000,33 +2000,34 @@ class InterfaceView(generic.ObjectView): class InterfaceCreateView(generic.ComponentCreateView): queryset = Interface.objects.all() model_form = forms.InterfaceForm - template_name = 'dcim/interface_create.html' + # template_name = 'dcim/interface_create.html' - def post(self, request): - """ - Override inherited post() method to handle request to assign newly created - interface objects (first object) to an IP Address object. - """ - form = self.form(request.POST, initial=request.GET) - new_objs = self.validate_form(request, form) - - if form.is_valid() and not form.errors: - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \ - request.user.has_perm('ipam.add_ipaddress'): - first_obj = new_objs[0].pk - return redirect( - f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}' - ) - else: - return redirect(self.get_return_url(request)) - - return render(request, self.template_name, { - 'obj_type': self.queryset.model._meta.verbose_name, - 'form': form, - 'return_url': self.get_return_url(request), - }) + # TODO: Figure out what to do with this + # def post(self, request): + # """ + # Override inherited post() method to handle request to assign newly created + # interface objects (first object) to an IP Address object. + # """ + # form = self.form(request.POST, initial=request.GET) + # new_objs = self.validate_form(request, form) + # + # if form.is_valid() and not form.errors: + # if '_addanother' in request.POST: + # return redirect(request.get_full_path()) + # elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \ + # request.user.has_perm('ipam.add_ipaddress'): + # first_obj = new_objs[0].pk + # return redirect( + # f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}' + # ) + # else: + # return redirect(self.get_return_url(request)) + # + # return render(request, self.template_name, { + # 'obj_type': self.queryset.model._meta.verbose_name, + # 'form': form, + # 'return_url': self.get_return_url(request), + # }) class InterfaceEditView(generic.ObjectEditView): diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 6fa90ea65..624c9e87f 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -275,12 +275,18 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Parent interface' + label='Parent interface', + query_params={ + 'virtual_machine_id': '$virtual_machine', + } ) bridge = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Bridged interface' + label='Bridged interface', + query_params={ + 'virtual_machine_id': '$virtual_machine', + } ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -293,6 +299,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Untagged VLAN', query_params={ 'group_id': '$vlan_group', + 'available_on_virtualmachine': '$virtual_machine', } ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -301,6 +308,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Tagged VLANs', query_params={ 'group_id': '$vlan_group', + 'available_on_virtualmachine': '$virtual_machine', } ) tags = DynamicModelMultipleChoiceField( @@ -324,15 +332,3 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - - # Restrict parent interface assignment by VM - self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) - self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) - - # Limit VLAN choices by virtual machine - self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) From 8ca09ec07fdffc3caabd16866818503d93ad3225 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 08:54:03 -0500 Subject: [PATCH 047/271] Clean up form display --- netbox/dcim/forms/models.py | 13 +++++++++++++ netbox/netbox/views/generic/object_views.py | 8 ++++---- netbox/templates/dcim/component_create.html | 4 ++-- netbox/templates/dcim/interface_create.html | 16 ---------------- netbox/templates/generic/object_edit.html | 8 ++++---- netbox/utilities/forms/fields.py | 6 +----- 6 files changed, 24 insertions(+), 31 deletions(-) delete mode 100644 netbox/templates/dcim/interface_create.html diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index f5988030c..d5b52aed3 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1097,6 +1097,7 @@ class ConsolePortForm(CustomFieldModelForm): widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), + 'speed': StaticSelect(), } @@ -1114,6 +1115,7 @@ class ConsoleServerPortForm(CustomFieldModelForm): widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), + 'speed': StaticSelect(), } @@ -1234,6 +1236,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] + # fieldsets = ( + # ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), + # ('Addressing', ('mac_address', 'wwn')), + # ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + # ('Related Interfaces', ('parent', 'bridge', 'lag')), + # ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + # ('Wireless', ( + # 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', + # 'wireless_lans', + # )), + # ) widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 009422038..682501895 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -707,8 +707,8 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View return render(request, self.template_name, { 'obj_type': self.queryset.model._meta.verbose_name, - 'form': form, - 'model_form': model_form, + 'replication_form': form, + 'form': model_form, 'return_url': self.get_return_url(request), }) @@ -725,8 +725,8 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View return render(request, self.template_name, { 'obj_type': self.queryset.model._meta.verbose_name, - 'form': form, - 'model_form': model_form, + 'replication_form': form, + 'form': model_form, 'return_url': self.get_return_url(request), }) diff --git a/netbox/templates/dcim/component_create.html b/netbox/templates/dcim/component_create.html index 367fc2d11..a8750e20e 100644 --- a/netbox/templates/dcim/component_create.html +++ b/netbox/templates/dcim/component_create.html @@ -2,6 +2,6 @@ {% load form_helpers %} {% block form %} - {% render_form form %} - {% render_form model_form %} + {% render_form replication_form %} + {{ block.super }} {% endblock form %} diff --git a/netbox/templates/dcim/interface_create.html b/netbox/templates/dcim/interface_create.html deleted file mode 100644 index 6b5486eff..000000000 --- a/netbox/templates/dcim/interface_create.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'generic/object_edit.html' %} - -{% block buttons %} - Cancel - {% if component_type == 'interface' and perms.ipam.add_ipaddress %} - - {% endif %} - - -{% endblock %} \ No newline at end of file diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index c7b4c8a8b..d72210dc7 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -40,7 +40,7 @@ {# Render grouped fields according to Form #} {% for group, fields in form.Meta.fieldsets %} -
    +
    {{ group }}
    @@ -51,7 +51,7 @@ {% endfor %} {% if form.custom_fields %} -
    +
    Custom Fields
    @@ -60,7 +60,7 @@ {% endif %} {% if form.comments %} -
    +
    Comments
    {% render_field form.comments %}
    @@ -68,7 +68,7 @@ {% else %} {# Render all fields in a single group #} -
    +
    {% block form_fields %}{% render_form form %}{% endblock %}
    {% endif %} diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 007215b6e..0e1b42b28 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -346,11 +346,7 @@ class ExpandableNameField(forms.CharField): if not self.help_text: self.help_text = """ Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Examples: -
      -
    • [ge,xe]-0/0/[0-9]
    • -
    • e[0-3][a-d,f]
    • -
    + are not supported. Example: [ge,xe]-0/0/[0-9] """ def to_python(self, value): From 4075cc8518da585f35536f667c76c5293a520bdc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 09:53:56 -0500 Subject: [PATCH 048/271] Restore front port component creation --- netbox/dcim/forms/models.py | 1 - netbox/dcim/forms/object_create.py | 93 +++++++++++++++++++++ netbox/dcim/views.py | 18 ++++ netbox/netbox/views/generic/object_views.py | 5 +- netbox/templates/generic/object_edit.html | 8 +- 5 files changed, 118 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index d5b52aed3..223f0a63f 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1267,7 +1267,6 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): class FrontPortForm(CustomFieldModelForm): rear_port = DynamicModelChoiceField( queryset=RearPort.objects.all(), - required=False, query_params={ 'device_id': '$device', } diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 8b8c00c6d..5e8daf38d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -9,6 +9,8 @@ from utilities.forms import ( __all__ = ( 'ComponentCreateForm', + 'FrontPortCreateForm', + 'FrontPortTemplateCreateForm', 'VirtualChassisCreateForm', ) @@ -41,6 +43,97 @@ class ComponentCreateForm(BootstrapMixin, forms.Form): }, code='label_pattern_mismatch') +class FrontPortTemplateCreateForm(ComponentCreateForm): + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'name_pattern', 'label_pattern', 'rear_port_set', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + device_type = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in device_type.frontporttemplates.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPortTemplate.objects.filter(device_type=device_type) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class FrontPortCreateForm(ComponentCreateForm): + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'name_pattern', 'label_pattern', 'rear_port_set', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available + # mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in device.frontports.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPort.objects.filter(device=device) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + class VirtualChassisCreateForm(CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 05416b720..bee7f9ef0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1219,8 +1219,17 @@ class InterfaceTemplateBulkDeleteView(generic.BulkDeleteView): class FrontPortTemplateCreateView(generic.ComponentCreateView): queryset = FrontPortTemplate.objects.all() + form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm + def initialize_forms(self, request): + form, model_form = super().initialize_forms(request) + + model_form.fields.pop('rear_port') + model_form.fields.pop('rear_port_position') + + return form, model_form + class FrontPortTemplateEditView(generic.ObjectEditView): queryset = FrontPortTemplate.objects.all() @@ -2085,8 +2094,17 @@ class FrontPortView(generic.ObjectView): class FrontPortCreateView(generic.ComponentCreateView): queryset = FrontPort.objects.all() + form = forms.FrontPortCreateForm model_form = forms.FrontPortForm + def initialize_forms(self, request): + form, model_form = super().initialize_forms(request) + + model_form.fields.pop('rear_port') + model_form.fields.pop('rear_port_position') + + return form, model_form + class FrontPortEditView(generic.ObjectEditView): queryset = FrontPort.objects.all() diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 682501895..a44d027b2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -735,7 +735,6 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View Validate form values and set errors on the form object as they are detected. If no errors are found, signal success messages. """ - logger = logging.getLogger('netbox.views.ComponentCreateView') if form.is_valid(): new_components = [] @@ -749,8 +748,8 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View data['name'] = name data['label'] = label - # if hasattr(form, 'get_iterative_data'): - # data.update(form.get_iterative_data(i)) + if hasattr(form, 'get_iterative_data'): + data.update(form.get_iterative_data(i)) component_form = self.model_form(data) diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index d72210dc7..c94981305 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -31,13 +31,15 @@ {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} {% block form %} {% if form.Meta.fieldsets %} + {# Render hidden fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + {# Render grouped fields according to Form #} {% for group, fields in form.Meta.fieldsets %}
    From e0319cc894f3218afe6d727eea08485634c7f188 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 10:22:00 -0500 Subject: [PATCH 049/271] Clean up form rendering --- netbox/dcim/forms/models.py | 28 ++++++++++----------- netbox/netbox/views/generic/object_views.py | 2 ++ netbox/templates/generic/object_edit.html | 8 ++++-- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 223f0a63f..6db3e2634 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1236,17 +1236,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] - # fieldsets = ( - # ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), - # ('Addressing', ('mac_address', 'wwn')), - # ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), - # ('Related Interfaces', ('parent', 'bridge', 'lag')), - # ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), - # ('Wireless', ( - # 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', - # 'wireless_lans', - # )), - # ) + fieldsets = ( + ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), + ('Addressing', ('mac_address', 'wwn')), + ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + ('Wireless', ( + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', + 'wireless_lans', + )), + ) widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), @@ -1358,9 +1358,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class InventoryItemForm(CustomFieldModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, @@ -1401,6 +1398,9 @@ class InventoryItemForm(CustomFieldModelForm): ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), ('Component', ('component_type', 'component_id')), ) + widgets = { + 'device': forms.HiddenInput(), + } # diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index a44d027b2..577dfa4bf 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -706,6 +706,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View form, model_form = self.initialize_forms(request) return render(request, self.template_name, { + 'obj': self.queryset.model, 'obj_type': self.queryset.model._meta.verbose_name, 'replication_form': form, 'form': model_form, @@ -724,6 +725,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View return redirect(self.get_return_url(request)) return render(request, self.template_name, { + 'obj': self.queryset.model, 'obj_type': self.queryset.model._meta.verbose_name, 'replication_form': form, 'form': model_form, diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index c94981305..5dc8f995d 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -29,7 +29,7 @@
    {% endif %} - + {% csrf_token %} {% block form %} @@ -47,7 +47,11 @@
    {{ group }}
    {% for name in fields %} - {% render_field form|getfield:name %} + {% with field=form|getfield:name %} + {% if not field.field.widget.is_hidden %} + {% render_field field %} + {% endif %} + {% endwith %} {% endfor %}
    {% endfor %} From a0836b6876f94ef596f02277323bf2c9017968a8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 11:06:34 -0500 Subject: [PATCH 050/271] Add inventory items panel to device component views --- netbox/templates/dcim/consoleport.html | 159 +++++++++--------- netbox/templates/dcim/consoleserverport.html | 1 + netbox/templates/dcim/frontport.html | 1 + .../dcim/inc/panels/inventory_items.html | 59 +++++++ netbox/templates/dcim/interface.html | 1 + netbox/templates/dcim/poweroutlet.html | 1 + netbox/templates/dcim/powerport.html | 1 + netbox/templates/dcim/rearport.html | 1 + 8 files changed, 145 insertions(+), 79 deletions(-) create mode 100644 netbox/templates/dcim/inc/panels/inventory_items.html diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 38cfb90ae..ed3c649dd 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -58,91 +58,92 @@
    {% if object.mark_connected %} - Marked as connected + Marked as connected {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} +
    Cable - {{ object.cable }} - - - -
    - + - - - - - - - - - - - - - - - - - {% endif %} -
    DeviceCable - {{ object.connected_endpoint.device }} + {{ object.cable }} + + +
    Name - {{ object.connected_endpoint.name }} -
    Type{{ object.connected_endpoint.get_type_display|placeholder }}
    Description{{ object.connected_endpoint.description|placeholder }}
    Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
    - {% else %} -
    - Not Connected - {% if perms.dcim.add_cable %} - - {% endif %} -
    - {% endif %} -
    + {% if object.connected_endpoint %} +
    Device + {{ object.connected_endpoint.device }} +
    Name + {{ object.connected_endpoint.name }} +
    Type{{ object.connected_endpoint.get_type_display|placeholder }}
    Description{{ object.connected_endpoint.description|placeholder }}
    Path Status + {% if object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    + {% else %} +
    + Not Connected + {% if perms.dcim.add_cable %} + + {% endif %} +
    + {% endif %} +
    + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index b44c4a9b8..b64e352e7 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -143,6 +143,7 @@ {% endif %}
    + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 05be82fc9..e11036f8a 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -129,6 +129,7 @@ {% endif %}
    + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %}
    diff --git a/netbox/templates/dcim/inc/panels/inventory_items.html b/netbox/templates/dcim/inc/panels/inventory_items.html new file mode 100644 index 000000000..c65b342b2 --- /dev/null +++ b/netbox/templates/dcim/inc/panels/inventory_items.html @@ -0,0 +1,59 @@ +{% load helpers %} + +
    +
    Inventory Items
    +
    + + + + + + + + + + + {% for item in object.inventory_items.all %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    NameLabelRole
    + {{ item.name }} + + {{ item.label|placeholder }} + + {% if item.role %} + {{ item.role }} + {% else %} + + {% endif %} + + {% if perms.dcim.change_inventoryitem %} + + + + {% endif %} + {% if perms.ipam.delete_inventoryitem %} + + + + {% endif %} +
    None
    +
    + +
    diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bd0569c39..a8b8da5cb 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -448,6 +448,7 @@ {% endif %} {% include 'ipam/inc/panels/fhrp_groups.html' %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 3f2c469af..90858c8d9 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -121,6 +121,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index f38edec8e..1ee85b6ba 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -131,6 +131,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 311ccd7ff..c56bf0c4f 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -117,6 +117,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} From 4c5a5c70b0129febfe191c70cce300aecba1aa8c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 11:19:46 -0500 Subject: [PATCH 051/271] Changelog for #7846 --- docs/release-notes/version-3.2.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 608a436a1..e0db0b13b 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -48,6 +48,7 @@ FIELD_CHOICES = { * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts +* [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group ### Other Changes @@ -76,7 +77,8 @@ FIELD_CHOICES = { * dcim.Interface * Added `module` field * dcim.InventoryItem - * Added `role` field + * Added `component_type`, `component_id`, and `role` fields + * Added read-only `component` field * dcim.PowerPort * Added `module` field * dcim.PowerOutlet From 21356b487a295c1a67bd9e0049f3514032d1a62c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 14:06:20 -0500 Subject: [PATCH 052/271] #7846: Show assigned component (if any) when creating inventory item --- netbox/dcim/forms/models.py | 6 +++--- netbox/dcim/tables/devices.py | 6 ++++-- netbox/dcim/views.py | 12 ++++++++++++ netbox/netbox/views/generic/object_views.py | 9 +++++++-- .../templates/dcim/inventoryitem_create.html | 18 ++++++++++++++++++ 5 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 netbox/templates/dcim/inventoryitem_create.html diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 6db3e2634..762924653 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1377,10 +1377,11 @@ class InventoryItemForm(CustomFieldModelForm): queryset=ContentType.objects.all(), limit_choices_to=MODULAR_COMPONENT_MODELS, required=False, - widget=StaticSelect + widget=forms.HiddenInput ) component_id = forms.IntegerField( - required=False + required=False, + widget=forms.HiddenInput ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -1396,7 +1397,6 @@ class InventoryItemForm(CustomFieldModelForm): fieldsets = ( ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), - ('Component', ('component_type', 'component_id')), ) widgets = { 'device': forms.HiddenInput(), diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 4eda4a937..0c3a5f6a1 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -781,6 +781,8 @@ class InventoryItemTable(DeviceComponentTable): linkify=True ) component = tables.Column( + accessor=Accessor('component'), + orderable=False, linkify=True ) discovered = BooleanColumn() @@ -792,8 +794,8 @@ class InventoryItemTable(DeviceComponentTable): class Meta(BaseTable.Meta): model = InventoryItem fields = ( - 'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'component', 'description', 'discovered', 'tags', + 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'description', 'discovered', 'tags', ) default_columns = ( 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bee7f9ef0..81f45f7d0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2399,6 +2399,18 @@ class InventoryItemEditView(generic.ObjectEditView): class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() model_form = forms.InventoryItemForm + template_name = 'dcim/inventoryitem_create.html' + + def alter_object(self, instance, request): + # Set component (if any) + component_type = request.GET.get('component_type') + component_id = request.GET.get('component_id') + + if component_type and component_id: + content_type = get_object_or_404(ContentType, pk=component_type) + instance.component = get_object_or_404(content_type.model_class(), pk=component_id) + + return instance class InventoryItemDeleteView(generic.ObjectDeleteView): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 577dfa4bf..3f5b14658 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -689,6 +689,9 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + def alter_object(self, instance, request): + return instance + def initialize_forms(self, request): data = request.POST if request.method == 'POST' else None initial_data = normalize_querydict(request.GET) @@ -704,9 +707,10 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View def get(self, request): form, model_form = self.initialize_forms(request) + instance = self.alter_object(self.queryset.model, request) return render(request, self.template_name, { - 'obj': self.queryset.model, + 'obj': instance, 'obj_type': self.queryset.model._meta.verbose_name, 'replication_form': form, 'form': model_form, @@ -715,6 +719,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View def post(self, request): form, model_form = self.initialize_forms(request) + instance = self.alter_object(self.queryset.model, request) self.validate_form(request, form) @@ -725,7 +730,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View return redirect(self.get_return_url(request)) return render(request, self.template_name, { - 'obj': self.queryset.model, + 'obj': instance, 'obj_type': self.queryset.model._meta.verbose_name, 'replication_form': form, 'form': model_form, diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html new file mode 100644 index 000000000..ef20a2188 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem_create.html @@ -0,0 +1,18 @@ +{% extends 'dcim/component_create.html' %} +{% load helpers %} + +{% block form %} + {% if obj.component %} +
    + + +
    + {% endif %} + {{ block.super }} +{% endblock %} From 3982f13569a22d58a71af926c5fe09788108a25d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 15:07:14 -0500 Subject: [PATCH 053/271] Show parent device/VM when creating new components --- netbox/dcim/forms/object_create.py | 25 ++++++++++++++++---- netbox/dcim/tests/test_forms.py | 13 +++++++--- netbox/dcim/views.py | 17 +++++++++++++ netbox/netbox/views/generic/object_views.py | 2 +- netbox/virtualization/forms/object_create.py | 6 ++++- 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 5e8daf38d..1fea886ea 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -8,7 +8,8 @@ from utilities.forms import ( ) __all__ = ( - 'ComponentCreateForm', + 'DeviceComponentCreateForm', + 'DeviceTypeComponentCreateForm', 'FrontPortCreateForm', 'FrontPortTemplateCreateForm', 'VirtualChassisCreateForm', @@ -43,14 +44,28 @@ class ComponentCreateForm(BootstrapMixin, forms.Form): }, code='label_pattern_mismatch') -class FrontPortTemplateCreateForm(ComponentCreateForm): +class DeviceTypeComponentCreateForm(ComponentCreateForm): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + ) + field_order = ('device_type', 'name_pattern', 'label_pattern') + + +class DeviceComponentCreateForm(ComponentCreateForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + field_order = ('device', 'name_pattern', 'label_pattern') + + +class FrontPortTemplateCreateForm(DeviceTypeComponentCreateForm): rear_port_set = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) field_order = ( - 'name_pattern', 'label_pattern', 'rear_port_set', + 'device_type', 'name_pattern', 'label_pattern', 'rear_port_set', ) def __init__(self, *args, **kwargs): @@ -88,14 +103,14 @@ class FrontPortTemplateCreateForm(ComponentCreateForm): } -class FrontPortCreateForm(ComponentCreateForm): +class FrontPortCreateForm(DeviceComponentCreateForm): rear_port_set = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) field_order = ( - 'name_pattern', 'label_pattern', 'rear_port_set', + 'device', 'name_pattern', 'label_pattern', 'rear_port_set', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 4c5de1284..53474314f 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,8 +1,9 @@ from django.test import TestCase -from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices +from dcim.choices import DeviceFaceChoices, DeviceStatusChoices from dcim.forms import * from dcim.models import * +from utilities.testing import create_test_device from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -118,15 +119,20 @@ class DeviceTestCase(TestCase): class LabelTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.device = create_test_device('Device 1') + def test_interface_label_count_valid(self): """ Test that generating an equal number of names and labels passes form validation. """ interface_data = { + 'device': self.device.pk, 'name_pattern': 'eth[0-9]', 'label_pattern': 'Interface[0-9]', } - form = ComponentCreateForm(interface_data) + form = DeviceComponentCreateForm(interface_data) self.assertTrue(form.is_valid()) @@ -135,10 +141,11 @@ class LabelTestCase(TestCase): Check that attempting to generate a differing number of names and labels results in a validation error. """ bad_interface_data = { + 'device': self.device.pk, 'name_pattern': 'eth[0-9]', 'label_pattern': 'Interface[0-1]', } - form = ComponentCreateForm(bad_interface_data) + form = DeviceComponentCreateForm(bad_interface_data) self.assertFalse(form.is_valid()) self.assertIn('label_pattern', form.errors) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 81f45f7d0..4e63c0e76 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1054,6 +1054,7 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView): class ConsolePortTemplateCreateView(generic.ComponentCreateView): queryset = ConsolePortTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.ConsolePortTemplateForm @@ -1087,6 +1088,7 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView): class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView): queryset = ConsoleServerPortTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.ConsoleServerPortTemplateForm @@ -1120,6 +1122,7 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerPortTemplateCreateView(generic.ComponentCreateView): queryset = PowerPortTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.PowerPortTemplateForm @@ -1153,6 +1156,7 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerOutletTemplateCreateView(generic.ComponentCreateView): queryset = PowerOutletTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.PowerOutletTemplateForm @@ -1186,6 +1190,7 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView): class InterfaceTemplateCreateView(generic.ComponentCreateView): queryset = InterfaceTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.InterfaceTemplateForm @@ -1261,6 +1266,7 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView): class RearPortTemplateCreateView(generic.ComponentCreateView): queryset = RearPortTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.RearPortTemplateForm @@ -1294,6 +1300,7 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView): class ModuleBayTemplateCreateView(generic.ComponentCreateView): queryset = ModuleBayTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.ModuleBayTemplateForm @@ -1327,6 +1334,7 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView): class DeviceBayTemplateCreateView(generic.ComponentCreateView): queryset = DeviceBayTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm model_form = forms.DeviceBayTemplateForm @@ -1741,6 +1749,7 @@ class ConsolePortView(generic.ObjectView): class ConsolePortCreateView(generic.ComponentCreateView): queryset = ConsolePort.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.ConsolePortForm @@ -1799,6 +1808,7 @@ class ConsoleServerPortView(generic.ObjectView): class ConsoleServerPortCreateView(generic.ComponentCreateView): queryset = ConsoleServerPort.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.ConsoleServerPortForm @@ -1857,6 +1867,7 @@ class PowerPortView(generic.ObjectView): class PowerPortCreateView(generic.ComponentCreateView): queryset = PowerPort.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.PowerPortForm @@ -1915,6 +1926,7 @@ class PowerOutletView(generic.ObjectView): class PowerOutletCreateView(generic.ComponentCreateView): queryset = PowerOutlet.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.PowerOutletForm @@ -2008,6 +2020,7 @@ class InterfaceView(generic.ObjectView): class InterfaceCreateView(generic.ComponentCreateView): queryset = Interface.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.InterfaceForm # template_name = 'dcim/interface_create.html' @@ -2161,6 +2174,7 @@ class RearPortView(generic.ObjectView): class RearPortCreateView(generic.ComponentCreateView): queryset = RearPort.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.RearPortForm @@ -2219,6 +2233,7 @@ class ModuleBayView(generic.ObjectView): class ModuleBayCreateView(generic.ComponentCreateView): queryset = ModuleBay.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.ModuleBayForm @@ -2273,6 +2288,7 @@ class DeviceBayView(generic.ObjectView): class DeviceBayCreateView(generic.ComponentCreateView): queryset = DeviceBay.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.DeviceBayForm @@ -2398,6 +2414,7 @@ class InventoryItemEditView(generic.ObjectEditView): class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() + form = forms.DeviceComponentCreateForm model_form = forms.InventoryItemForm template_name = 'dcim/inventoryitem_create.html' diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 3f5b14658..5895a7a6e 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -681,7 +681,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ queryset = None - form = ComponentCreateForm + form = None model_form = None template_name = 'dcim/component_create.html' patterned_fields = ('name', 'label') diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index f275469fd..feab3bb3a 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,6 +1,7 @@ from django import forms -from utilities.forms import BootstrapMixin, ExpandableNameField +from utilities.forms import BootstrapMixin, DynamicModelChoiceField, ExpandableNameField +from .models import VirtualMachine __all__ = ( 'VMInterfaceCreateForm', @@ -8,6 +9,9 @@ __all__ = ( class VMInterfaceCreateForm(BootstrapMixin, forms.Form): + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all() + ) name_pattern = ExpandableNameField( label='Name' ) From a0d6cb1fd30afb1fabb16ff40b6ae0bb5c5e98c1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 17:14:04 -0500 Subject: [PATCH 054/271] Simplify theme color palette --- netbox/project-static/dist/netbox-dark.css | Bin 789153 -> 374499 bytes netbox/project-static/dist/netbox-light.css | Bin 493807 -> 232310 bytes netbox/project-static/dist/netbox-print.css | Bin 1624275 -> 728000 bytes netbox/project-static/styles/theme-base.scss | 89 ------------------ netbox/project-static/styles/theme-dark.scss | 19 ++-- netbox/project-static/styles/theme-light.scss | 51 ++++++---- 6 files changed, 48 insertions(+), 111 deletions(-) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index e711685bf40042d2b0918218882f0acba69a0d49..fe89a1b48d99d273207bc68dd535684ecc7ab9ab 100644 GIT binary patch delta 5570 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 diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 23dc8d3821e58e7264fe4bd3109dea298dc227bf..f8e59ef99eee45a9e75222afd766761c2f8163e0 100644 GIT binary patch delta 6935 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 literal 493807 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З+?jCpyYLbl^-w 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

    mwN;!~^0u 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

    GkmN`8V7h}xh~ef1o;Hqy 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 F{{ZW791s8i diff --git a/netbox/project-static/styles/theme-base.scss b/netbox/project-static/styles/theme-base.scss index 26a1811bc..97f6dd020 100644 --- a/netbox/project-static/styles/theme-base.scss +++ b/netbox/project-static/styles/theme-base.scss @@ -33,95 +33,6 @@ $darkest: #171b1d; @import '../node_modules/bootstrap/scss/variables'; -// Make color palette colors available as theme colors. -// For example, you could use `.bg-red-100`, if needed. -$theme-color-addons: ( - 'darker': $darker, - 'darkest': $darkest, - 'gray': $gray-400, - 'gray-100': $gray-100, - 'gray-200': $gray-200, - 'gray-300': $gray-300, - 'gray-400': $gray-400, - 'gray-500': $gray-500, - 'gray-600': $gray-600, - 'gray-700': $gray-700, - 'gray-800': $gray-800, - 'gray-900': $gray-900, - 'red-100': $red-100, - 'red-200': $red-200, - 'red-300': $red-300, - 'red-400': $red-400, - 'red-500': $red-500, - 'red-600': $red-600, - 'red-700': $red-700, - 'red-800': $red-800, - 'red-900': $red-900, - 'yellow-100': $yellow-100, - 'yellow-200': $yellow-200, - 'yellow-300': $yellow-300, - 'yellow-400': $yellow-400, - 'yellow-500': $yellow-500, - 'yellow-600': $yellow-600, - 'yellow-700': $yellow-700, - 'yellow-800': $yellow-800, - 'yellow-900': $yellow-900, - 'green-100': $green-100, - 'green-200': $green-200, - 'green-300': $green-300, - 'green-400': $green-400, - 'green-500': $green-500, - 'green-600': $green-600, - 'green-700': $green-700, - 'green-800': $green-800, - 'green-900': $green-900, - 'blue-100': $blue-100, - 'blue-200': $blue-200, - 'blue-300': $blue-300, - 'blue-400': $blue-400, - 'blue-500': $blue-500, - 'blue-600': $blue-600, - 'blue-700': $blue-700, - 'blue-800': $blue-800, - 'blue-900': $blue-900, - 'cyan-100': $cyan-100, - 'cyan-200': $cyan-200, - 'cyan-300': $cyan-300, - 'cyan-400': $cyan-400, - 'cyan-500': $cyan-500, - 'cyan-600': $cyan-600, - 'cyan-700': $cyan-700, - 'cyan-800': $cyan-800, - 'cyan-900': $cyan-900, - 'indigo-100': $indigo-100, - 'indigo-200': $indigo-200, - 'indigo-300': $indigo-300, - 'indigo-400': $indigo-400, - 'indigo-500': $indigo-500, - 'indigo-600': $indigo-600, - 'indigo-700': $indigo-700, - 'indigo-800': $indigo-800, - 'indigo-900': $indigo-900, - 'purple-100': $purple-100, - 'purple-200': $purple-200, - 'purple-300': $purple-300, - 'purple-400': $purple-400, - 'purple-500': $purple-500, - 'purple-600': $purple-600, - 'purple-700': $purple-700, - 'purple-800': $purple-800, - 'purple-900': $purple-900, - 'pink-100': $pink-100, - 'pink-200': $pink-200, - 'pink-300': $pink-300, - 'pink-400': $pink-400, - 'pink-500': $pink-500, - 'pink-600': $pink-600, - 'pink-700': $pink-700, - 'pink-800': $pink-800, - 'pink-900': $pink-900, -); - // This is the same value as the default from Bootstrap, but it needs to be in scope prior to // importing _variables.scss from Bootstrap. $btn-close-width: 1em; diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss index c5fb5dcf1..c787d38bf 100644 --- a/netbox/project-static/styles/theme-dark.scss +++ b/netbox/project-static/styles/theme-dark.scss @@ -3,6 +3,7 @@ @use 'sass:map'; @import './theme-base'; +// Theme colors (BS5 classes) $primary: $blue-300; $secondary: $gray-500; $success: $green-300; @@ -13,6 +14,7 @@ $light: $gray-300; $dark: $gray-500; $theme-colors: ( + // BS5 theme colors 'primary': $primary, 'secondary': $secondary, 'success': $success, @@ -21,18 +23,23 @@ $theme-colors: ( 'danger': $danger, 'light': $light, 'dark': $dark, - 'red': $red-300, - 'yellow': $yellow-300, - 'green': $green-300, + + // General-purpose palette 'blue': $blue-300, - 'cyan': $cyan-300, 'indigo': $indigo-300, 'purple': $purple-300, 'pink': $pink-300, + 'red': $red-300, + 'orange': $orange-300, + 'yellow': $yellow-300, + 'green': $green-300, + 'teal': $teal-300, + 'cyan': $cyan-300, + 'gray': $gray-300, + 'black': $black, + 'white': $white, ); -$theme-colors: map-merge($theme-colors, $theme-color-addons); - // Gradient $gradient: linear-gradient(180deg, rgba($white, 0.15), rgba($white, 0)); diff --git a/netbox/project-static/styles/theme-light.scss b/netbox/project-static/styles/theme-light.scss index 4e638c75e..05ead54b5 100644 --- a/netbox/project-static/styles/theme-light.scss +++ b/netbox/project-static/styles/theme-light.scss @@ -4,23 +4,42 @@ $input-border-color: $gray-200; -$theme-colors: map-merge( - $theme-colors, - ( - 'primary': #337ab7, - 'info': #54d6f0, - 'red': $red-500, - 'yellow': $yellow-500, - 'green': $green-500, - 'blue': $blue-500, - 'cyan': $cyan-500, - 'indigo': $indigo-500, - 'purple': $purple-500, - 'pink': $pink-500, - ) -); +// Theme colors (BS5 classes) +$primary: #337ab7; +$secondary: $gray-600; +$success: $green-500; +$info: #54d6f0; +$warning: $yellow-500; +$danger: $red-500; +$light: $gray-200; +$dark: $gray-800; -$theme-colors: map-merge($theme-colors, $theme-color-addons); +$theme-colors: ( + // BS5 theme colors + 'primary': $primary, + 'secondary': $secondary, + 'success': $success, + 'info': $info, + 'warning': $warning, + 'danger': $danger, + 'light': $light, + 'dark': $dark, + + // General-purpose palette + 'blue': $blue-500, + 'indigo': $indigo-500, + 'purple': $purple-500, + 'pink': $pink-500, + 'red': $red-500, + 'orange': $orange-500, + 'yellow': $yellow-500, + 'green': $green-500, + 'teal': $teal-500, + 'cyan': $cyan-500, + 'gray': $gray-500, + 'black': $black, + 'white': $white, +); $light: $gray-200; From b3ea007e0a286ad414162a53b10dc248e134723f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 20:18:07 -0500 Subject: [PATCH 055/271] Update ChoiceSets to use base colors --- netbox/circuits/choices.py | 12 ++++---- netbox/dcim/choices.py | 52 ++++++++++++++++---------------- netbox/extras/choices.py | 24 +++++++-------- netbox/ipam/choices.py | 46 ++++++++++++++-------------- netbox/virtualization/choices.py | 12 ++++---- 5 files changed, 73 insertions(+), 73 deletions(-) diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 007b45298..fd58abb97 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -16,12 +16,12 @@ class CircuitStatusChoices(ChoiceSet): STATUS_DECOMMISSIONED = 'decommissioned' CHOICES = [ - (STATUS_PLANNED, 'Planned', 'info'), - (STATUS_PROVISIONING, 'Provisioning', 'primary'), - (STATUS_ACTIVE, 'Active', 'success'), - (STATUS_OFFLINE, 'Offline', 'danger'), - (STATUS_DEPROVISIONING, 'Deprovisioning', 'warning'), - (STATUS_DECOMMISSIONED, 'Decommissioned', 'secondary'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_PROVISIONING, 'Provisioning', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_OFFLINE, 'Offline', 'red'), + (STATUS_DEPROVISIONING, 'Deprovisioning', 'yellow'), + (STATUS_DECOMMISSIONED, 'Decommissioned', 'gray'), ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index bcc926580..368ee1336 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -15,11 +15,11 @@ class SiteStatusChoices(ChoiceSet): STATUS_RETIRED = 'retired' CHOICES = [ - (STATUS_PLANNED, 'Planned', 'info'), - (STATUS_STAGING, 'Staging', 'primary'), - (STATUS_ACTIVE, 'Active', 'primary'), - (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), - (STATUS_RETIRED, 'Retired', 'danger'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), ] @@ -69,11 +69,11 @@ class RackStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_RESERVED, 'Reserved', 'warning'), - (STATUS_AVAILABLE, 'Available', 'success'), - (STATUS_PLANNED, 'Planned', 'info'), - (STATUS_ACTIVE, 'Active', 'primary'), - (STATUS_DEPRECATED, 'Deprecated', 'danger'), + (STATUS_RESERVED, 'Reserved', 'yellow'), + (STATUS_AVAILABLE, 'Available', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_ACTIVE, 'Active', 'blue'), + (STATUS_DEPRECATED, 'Deprecated', 'red'), ] @@ -141,13 +141,13 @@ class DeviceStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = [ - (STATUS_OFFLINE, 'Offline', 'warning'), - (STATUS_ACTIVE, 'Active', 'success'), - (STATUS_PLANNED, 'Planned', 'info'), - (STATUS_STAGED, 'Staged', 'primary'), - (STATUS_FAILED, 'Failed', 'danger'), - (STATUS_INVENTORY, 'Inventory', 'secondary'), - (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGED, 'Staged', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + (STATUS_INVENTORY, 'Inventory', 'purple'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), ] @@ -1121,9 +1121,9 @@ class LinkStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( - (STATUS_CONNECTED, 'Connected', 'success'), - (STATUS_PLANNED, 'Planned', 'info'), - (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), + (STATUS_CONNECTED, 'Connected', 'green'), + (STATUS_PLANNED, 'Planned', 'blue'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), ) @@ -1162,10 +1162,10 @@ class PowerFeedStatusChoices(ChoiceSet): STATUS_FAILED = 'failed' CHOICES = [ - (STATUS_OFFLINE, 'Offline', 'warning'), - (STATUS_ACTIVE, 'Active', 'success'), - (STATUS_PLANNED, 'Planned', 'info'), - (STATUS_FAILED, 'Failed', 'danger'), + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), ] @@ -1175,8 +1175,8 @@ class PowerFeedTypeChoices(ChoiceSet): TYPE_REDUNDANT = 'redundant' CHOICES = ( - (TYPE_PRIMARY, 'Primary', 'success'), - (TYPE_REDUNDANT, 'Redundant', 'info'), + (TYPE_PRIMARY, 'Primary', 'green'), + (TYPE_REDUNDANT, 'Redundant', 'cyan'), ) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index ff117c4e5..c01e64831 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -91,9 +91,9 @@ class ObjectChangeActionChoices(ChoiceSet): ACTION_DELETE = 'delete' CHOICES = ( - (ACTION_CREATE, 'Created', 'success'), - (ACTION_UPDATE, 'Updated', 'primary'), - (ACTION_DELETE, 'Deleted', 'danger'), + (ACTION_CREATE, 'Created', 'green'), + (ACTION_UPDATE, 'Updated', 'blue'), + (ACTION_DELETE, 'Deleted', 'red'), ) @@ -109,10 +109,10 @@ class JournalEntryKindChoices(ChoiceSet): KIND_DANGER = 'danger' CHOICES = ( - (KIND_INFO, 'Info', 'info'), - (KIND_SUCCESS, 'Success', 'success'), - (KIND_WARNING, 'Warning', 'warning'), - (KIND_DANGER, 'Danger', 'danger'), + (KIND_INFO, 'Info', 'cyan'), + (KIND_SUCCESS, 'Success', 'green'), + (KIND_WARNING, 'Warning', 'yellow'), + (KIND_DANGER, 'Danger', 'red'), ) @@ -129,11 +129,11 @@ class LogLevelChoices(ChoiceSet): LOG_FAILURE = 'failure' CHOICES = ( - (LOG_DEFAULT, 'Default', 'secondary'), - (LOG_SUCCESS, 'Success', 'success'), - (LOG_INFO, 'Info', 'info'), - (LOG_WARNING, 'Warning', 'warning'), - (LOG_FAILURE, 'Failure', 'danger'), + (LOG_DEFAULT, 'Default', 'gray'), + (LOG_SUCCESS, 'Success', 'green'), + (LOG_INFO, 'Info', 'cyan'), + (LOG_WARNING, 'Warning', 'yellow'), + (LOG_FAILURE, 'Failure', 'red'), ) diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 849f6a6bc..f9e4afffb 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -25,10 +25,10 @@ class PrefixStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_CONTAINER, 'Container', 'secondary'), - (STATUS_ACTIVE, 'Active', 'primary'), - (STATUS_RESERVED, 'Reserved', 'info'), - (STATUS_DEPRECATED, 'Deprecated', 'danger'), + (STATUS_CONTAINER, 'Container', 'gray'), + (STATUS_ACTIVE, 'Active', 'blue'), + (STATUS_RESERVED, 'Reserved', 'cyan'), + (STATUS_DEPRECATED, 'Deprecated', 'red'), ] @@ -44,9 +44,9 @@ class IPRangeStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, 'Active', 'primary'), - (STATUS_RESERVED, 'Reserved', 'info'), - (STATUS_DEPRECATED, 'Deprecated', 'danger'), + (STATUS_ACTIVE, 'Active', 'blue'), + (STATUS_RESERVED, 'Reserved', 'cyan'), + (STATUS_DEPRECATED, 'Deprecated', 'red'), ] @@ -64,11 +64,11 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_SLAAC = 'slaac' CHOICES = [ - (STATUS_ACTIVE, 'Active', 'primary'), - (STATUS_RESERVED, 'Reserved', 'info'), - (STATUS_DEPRECATED, 'Deprecated', 'danger'), - (STATUS_DHCP, 'DHCP', 'success'), - (STATUS_SLAAC, 'SLAAC', 'success'), + (STATUS_ACTIVE, 'Active', 'blue'), + (STATUS_RESERVED, 'Reserved', 'cyan'), + (STATUS_DEPRECATED, 'Deprecated', 'red'), + (STATUS_DHCP, 'DHCP', 'green'), + (STATUS_SLAAC, 'SLAAC', 'purple'), ] @@ -84,14 +84,14 @@ class IPAddressRoleChoices(ChoiceSet): ROLE_CARP = 'carp' CHOICES = ( - (ROLE_LOOPBACK, 'Loopback', 'secondary'), - (ROLE_SECONDARY, 'Secondary', 'primary'), - (ROLE_ANYCAST, 'Anycast', 'warning'), - (ROLE_VIP, 'VIP'), - (ROLE_VRRP, 'VRRP', 'success'), - (ROLE_HSRP, 'HSRP', 'success'), - (ROLE_GLBP, 'GLBP', 'success'), - (ROLE_CARP, 'CARP'), 'success', + (ROLE_LOOPBACK, 'Loopback', 'gray'), + (ROLE_SECONDARY, 'Secondary', 'blue'), + (ROLE_ANYCAST, 'Anycast', 'yellow'), + (ROLE_VIP, 'VIP', 'purple'), + (ROLE_VRRP, 'VRRP', 'green'), + (ROLE_HSRP, 'HSRP', 'green'), + (ROLE_GLBP, 'GLBP', 'green'), + (ROLE_CARP, 'CARP'), 'green', ) @@ -141,9 +141,9 @@ class VLANStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, 'Active', 'primary'), - (STATUS_RESERVED, 'Reserved', 'info'), - (STATUS_DEPRECATED, 'Deprecated', 'danger'), + (STATUS_ACTIVE, 'Active', 'blue'), + (STATUS_RESERVED, 'Reserved', 'cyan'), + (STATUS_DEPRECATED, 'Deprecated', 'red'), ] diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 1aaaf6bf9..bb9220d09 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -16,10 +16,10 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = [ - (STATUS_OFFLINE, 'Offline', 'warning'), - (STATUS_ACTIVE, 'Active', 'success'), - (STATUS_PLANNED, 'Planned', 'info'), - (STATUS_STAGED, 'Staged', 'primary'), - (STATUS_FAILED, 'Failed', 'danger'), - (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGED, 'Staged', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), ] From b60ace80be3896966005b03ce93641afd20851b5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Dec 2021 20:21:35 -0500 Subject: [PATCH 056/271] Update documentation for FIELD_CHOICES --- docs/configuration/optional-settings.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index fcebf7af6..110ca9322 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -161,16 +161,16 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] Default: Empty dictionary -Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices list. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices list. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) For example, to specify a custom set of choices for the site status field: ```python FIELD_CHOICES = { 'dcim.Site.status': ( - ('foo', 'Foo'), - ('bar', 'Bar'), - ('baz', 'Baz'), + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), ) } ``` @@ -190,6 +190,22 @@ The following model field support configurable choices: * `ipam.VLAN.status` * `virtualization.VirtualMachine.status` +The following colors are supported: + +* `blue` +* `indigo` +* `purple` +* `pink` +* `red` +* `orange` +* `yellow` +* `green` +* `teal` +* `cyan` +* `gray` +* `black` +* `white` + --- ## HTTP_PROXIES From 4c15f4a84f9d18ece8d908c3bdaf0e92bb723666 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Dec 2021 15:02:25 -0500 Subject: [PATCH 057/271] Initial work on #8118 --- netbox/dcim/api/nested_serializers.py | 10 ++ netbox/dcim/api/serializers.py | 34 +++++ netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 6 + netbox/dcim/constants.py | 12 ++ netbox/dcim/filtersets.py | 44 +++++++ netbox/dcim/forms/bulk_edit.py | 26 ++++ netbox/dcim/forms/models.py | 43 +++++++ netbox/dcim/forms/object_import.py | 85 ++++++++++--- netbox/dcim/graphql/schema.py | 3 + netbox/dcim/graphql/types.py | 9 ++ .../0148_inventoryitem_templates.py | 43 +++++++ .../dcim/models/device_component_templates.py | 120 ++++++++++++++++-- netbox/dcim/models/devices.py | 3 + netbox/dcim/tables/devicetypes.py | 26 +++- netbox/dcim/tests/test_api.py | 51 ++++++++ netbox/dcim/tests/test_filtersets.py | 80 ++++++++++++ netbox/dcim/tests/test_views.py | 72 ++++++++++- netbox/dcim/urls.py | 11 +- netbox/dcim/views.py | 54 ++++++++ netbox/templates/dcim/devicetype/base.html | 11 ++ 21 files changed, 712 insertions(+), 32 deletions(-) create mode 100644 netbox/dcim/migrations/0148_inventoryitem_templates.py diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 0cd112a1d..0ec0e07e0 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -21,6 +21,7 @@ __all__ = [ 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', 'NestedInventoryItemRoleSerializer', + 'NestedInventoryItemTemplateSerializer', 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', @@ -231,6 +232,15 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedInventoryItemTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.InventoryItemTemplate + fields = ['id', 'url', 'display', 'name', '_depth'] + + # # Devices # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 30f451e84..3bc369a64 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -447,6 +447,40 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] +class InventoryItemTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + device_type = NestedDeviceTypeSerializer() + parent = serializers.PrimaryKeyRelatedField( + queryset=InventoryItemTemplate.objects.all(), + allow_null=True, + default=None + ) + role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', + 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_component(self, obj): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.component, context=context).data + + # # Devices # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index be963d36d..c6f48aed3 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -31,6 +31,7 @@ router.register('front-port-templates', views.FrontPortTemplateViewSet) router.register('rear-port-templates', views.RearPortTemplateViewSet) router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) +router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) # Device/modules router.register('device-roles', views.DeviceRoleViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 479abf7b2..31c1fd1d0 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -350,6 +350,12 @@ class DeviceBayTemplateViewSet(ModelViewSet): filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateViewSet(ModelViewSet): + queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') + serializer_class = serializers.InventoryItemTemplateSerializer + filterset_class = filtersets.InventoryItemTemplateFilterSet + + # # Device roles # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 00126ebf8..45844b049 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -62,6 +62,18 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage # Device components # +MODULAR_COMPONENT_TEMPLATE_MODELS = Q( + app_label='dcim', + model__in=( + 'consoleporttemplate', + 'consoleserverporttemplate', + 'frontporttemplate', + 'interfacetemplate', + 'poweroutlettemplate', + 'powerporttemplate', + 'rearporttemplate', + )) + MODULAR_COMPONENT_MODELS = Q( app_label='dcim', model__in=( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 14a2ae3ee..9069ab25c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -40,6 +40,7 @@ __all__ = ( 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', 'InventoryItemRoleFilterSet', + 'InventoryItemTemplateFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', 'ModuleBayFilterSet', @@ -687,6 +688,49 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name'] +class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemTemplate.objects.all(), + label='Parent inventory item (ID)', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=InventoryItemRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + component_type = ContentTypeFilter() + component_id = MultiValueNumberFilter() + + class Meta: + model = InventoryItemTemplate + fields = ['id', 'name', 'label', 'part_id'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(part_id__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) + + class DeviceRoleFilterSet(OrganizationalModelFilterSet): tag = TagFilter() diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 93a90a1cb..3cd8ec35e 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -31,6 +31,7 @@ __all__ = ( 'InterfaceTemplateBulkEditForm', 'InventoryItemBulkEditForm', 'InventoryItemRoleBulkEditForm', + 'InventoryItemTemplateBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', 'ModuleBulkEditForm', @@ -907,6 +908,31 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'description') +class InventoryItemTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItemTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + + class Meta: + nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description'] + + # # Device components # diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index e2c343028..9fcea7661 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -38,6 +38,7 @@ __all__ = ( 'InterfaceTemplateForm', 'InventoryItemForm', 'InventoryItemRoleForm', + 'InventoryItemTemplateForm', 'LocationForm', 'ManufacturerForm', 'ModuleForm', @@ -1073,6 +1074,48 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } +class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): + parent = DynamicModelChoiceField( + queryset=InventoryItem.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_MODELS, + required=False, + widget=forms.HiddenInput + ) + component_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + 'component_type', 'component_id', + ] + fieldsets = ( + ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')), + ('Hardware', ('manufacturer', 'part_id')), + ) + widgets = { + 'device_type': forms.HiddenInput(), + } + + # # Device components # diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 36c6ae8bc..afbcd6543 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -11,6 +11,7 @@ __all__ = ( 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', + 'InventoryItemTemplateImportForm', 'ModuleBayTemplateImportForm', 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', @@ -49,24 +50,7 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): # class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - - def clean_device_type(self): - # Limit fields referencing other components to the parent DeviceType - if data := self.cleaned_data['device_type']: - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: - field.queryset = field.queryset.filter(device_type=data) - - return data - - def clean_module_type(self): - # Limit fields referencing other components to the parent ModuleType - if data := self.cleaned_data['module_type']: - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: - field.queryset = field.queryset.filter(module_type=data) - - return data + pass class ConsolePortTemplateImportForm(ComponentTemplateImportForm): @@ -109,6 +93,20 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(module_type=module_type) + + return module_type + class InterfaceTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( @@ -131,6 +129,20 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): to_field_name='name' ) + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(module_type=module_type) + + return module_type + class Meta: model = FrontPortTemplate fields = [ @@ -166,3 +178,40 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): fields = [ 'device_type', 'name', 'label', 'description', ] + + +class InventoryItemTemplateImportForm(ComponentTemplateImportForm): + parent = forms.ModelChoiceField( + queryset=InventoryItemTemplate.objects.all(), + required=False + ) + role = forms.ModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + ] + + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(module_type=module_type) + + return module_type diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 8e03ab409..1d5b6a580 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -53,6 +53,9 @@ class DCIMQuery(graphene.ObjectType): inventory_item_role = ObjectField(InventoryItemRoleType) inventory_item_role_list = ObjectListField(InventoryItemRoleType) + inventory_item_template = ObjectField(InventoryItemTemplateType) + inventory_item_template_list = ObjectListField(InventoryItemTemplateType) + location = ObjectField(LocationType) location_list = ObjectListField(LocationType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index b2a94c3ed..a47ca40ca 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -26,6 +26,7 @@ __all__ = ( 'InterfaceTemplateType', 'InventoryItemType', 'InventoryItemRoleType', + 'InventoryItemTemplateType', 'LocationType', 'ManufacturerType', 'ModuleType', @@ -172,6 +173,14 @@ class DeviceBayTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.InventoryItemTemplate + fields = '__all__' + filterset_class = filtersets.InventoryItemTemplateFilterSet + + class DeviceRoleType(OrganizationalObjectType): class Meta: diff --git a/netbox/dcim/migrations/0148_inventoryitem_templates.py b/netbox/dcim/migrations/0148_inventoryitem_templates.py new file mode 100644 index 000000000..8c3fe78c3 --- /dev/null +++ b/netbox/dcim/migrations/0148_inventoryitem_templates.py @@ -0,0 +1,43 @@ +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0147_inventoryitem_component'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItemTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('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)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('component_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('part_id', models.CharField(blank=True, max_length=50)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitemtemplates', to='dcim.devicetype')), + ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')), + ], + options={ + 'ordering': ('device_type__id', 'parent__id', '_name'), + 'unique_together': {('device_type', 'parent', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 71fed25c5..b3ede8282 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,15 +1,20 @@ +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from extras.utils import extras_features from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField +from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from .device_components import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, ModuleBay, PowerOutlet, PowerPort, RearPort, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, + RearPort, ) @@ -19,6 +24,7 @@ __all__ = ( 'DeviceBayTemplate', 'FrontPortTemplate', 'InterfaceTemplate', + 'InventoryItemTemplate', 'ModuleBayTemplate', 'PowerOutletTemplate', 'PowerPortTemplate', @@ -140,6 +146,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel): blank=True ) + component_model = ConsolePort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -148,7 +156,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ) def instantiate(self, **kwargs): - return ConsolePort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -167,6 +175,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): blank=True ) + component_model = ConsoleServerPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -175,7 +185,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ) def instantiate(self, **kwargs): - return ConsoleServerPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -206,6 +216,8 @@ class PowerPortTemplate(ModularComponentTemplateModel): help_text="Allocated power draw (watts)" ) + component_model = PowerPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -214,7 +226,7 @@ class PowerPortTemplate(ModularComponentTemplateModel): ) def instantiate(self, **kwargs): - return PowerPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -257,6 +269,8 @@ class PowerOutletTemplate(ModularComponentTemplateModel): help_text="Phase (for three-phase feeds)" ) + component_model = PowerOutlet + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -283,7 +297,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs) else: power_port = None - return PowerOutlet( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -314,6 +328,8 @@ class InterfaceTemplate(ModularComponentTemplateModel): verbose_name='Management only' ) + component_model = Interface + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -322,7 +338,7 @@ class InterfaceTemplate(ModularComponentTemplateModel): ) def instantiate(self, **kwargs): - return Interface( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -356,6 +372,8 @@ class FrontPortTemplate(ModularComponentTemplateModel): ] ) + component_model = FrontPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -391,7 +409,7 @@ class FrontPortTemplate(ModularComponentTemplateModel): rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs) else: rear_port = None - return FrontPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -422,6 +440,8 @@ class RearPortTemplate(ModularComponentTemplateModel): ] ) + component_model = RearPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -430,7 +450,7 @@ class RearPortTemplate(ModularComponentTemplateModel): ) def instantiate(self, **kwargs): - return RearPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -451,12 +471,14 @@ class ModuleBayTemplate(ComponentTemplateModel): help_text='Identifier to reference when renaming installed components' ) + component_model = ModuleBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return ModuleBay( + return self.component_model( device=device, name=self.name, label=self.label, @@ -469,12 +491,14 @@ class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. """ + component_model = DeviceBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return DeviceBay( + return self.component_model( device=device, name=self.name, label=self.label @@ -485,3 +509,79 @@ class DeviceBayTemplate(ComponentTemplateModel): raise ValidationError( f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays." ) + + +@extras_features('webhooks') +class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): + """ + A template for an InventoryItem to be created for a new parent Device. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='child_items', + blank=True, + null=True, + db_index=True + ) + component_type = models.ForeignKey( + to=ContentType, + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + component_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + component = GenericForeignKey( + ct_field='component_type', + fk_field='component_id' + ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + part_id = models.CharField( + max_length=50, + verbose_name='Part ID', + blank=True, + help_text='Manufacturer-assigned part identifier' + ) + + objects = TreeManager() + component_model = InventoryItem + + class Meta: + ordering = ('device_type__id', 'parent__id', '_name') + unique_together = ('device_type', 'parent', 'name') + + def instantiate(self, **kwargs): + parent = InventoryItemTemplate.objects.get(name=self.parent.name, **kwargs) if self.parent else None + if self.component: + model = self.component.component_model + component = model.objects.get(name=self.component.name, **kwargs) + else: + component = None + return self.component_model( + parent=parent, + name=self.name, + label=self.label, + component=component, + role=self.role, + manufacturer=self.manufacturer, + part_id=self.part_id, + **kwargs + ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8d0a7ae19..631f0c8c1 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -933,6 +933,9 @@ class Device(PrimaryModel, ConfigContextModel): DeviceBay.objects.bulk_create( [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] ) + # Avoid bulk_create to handle MPTT + for x in self.device_type.inventoryitemtemplates.all(): + x.instantiate(device=self).save() # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index ad4c4d844..885fe69a4 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,8 +1,9 @@ import django_tables2 as tables +from django_tables2.utils import Accessor from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, - Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, + InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, @@ -15,6 +16,7 @@ __all__ = ( 'DeviceTypeTable', 'FrontPortTemplateTable', 'InterfaceTemplateTable', + 'InventoryItemTemplateTable', 'ManufacturerTable', 'ModuleBayTemplateTable', 'PowerOutletTemplateTable', @@ -223,3 +225,25 @@ class DeviceBayTemplateTable(ComponentTemplateTable): model = DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" + + +class InventoryItemTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=InventoryItemTemplate, + buttons=('edit', 'delete') + ) + role = tables.Column( + linkify=True + ) + manufacturer = tables.Column( + linkify=True + ) + component = tables.Column( + accessor=Accessor('component'), + orderable=False + ) + + class Meta(ComponentTemplateTable.Meta): + model = InventoryItemTemplate + fields = ('pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions') + empty_text = "None" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index b3c41e277..0c9b918df 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -897,6 +897,57 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): ] +class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase): + model = InventoryItemTemplate + brief_fields = ['_depth', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, + model='Device Type 1', + slug='device-type-1' + ) + role = InventoryItemRole.objects.create(name='Inventory Item Role 1', slug='inventory-item-role-1') + + inventory_item_templates = ( + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturer, role=role), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturer, role=role), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturer, role=role), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 4', manufacturer=manufacturer, role=role), + ) + for item in inventory_item_templates: + item.save() + + cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template 5', + 'manufacturer': manufacturer.pk, + 'role': role.pk, + 'parent': inventory_item_templates[3].pk, + }, + { + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template 6', + 'manufacturer': manufacturer.pk, + 'role': role.pk, + 'parent': inventory_item_templates[3].pk, + }, + { + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template 7', + 'manufacturer': manufacturer.pk, + 'role': role.pk, + 'parent': inventory_item_templates[3].pk, + }, + ] + + class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f53705336..2973e46e7 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1214,6 +1214,86 @@ class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class InventoryItemTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = InventoryItemTemplate.objects.all() + filterset = InventoryItemTemplateFilterSet + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + device_types = ( + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1'), + DeviceType(manufacturer=manufacturers[0], model='Model 2', slug='model-2'), + DeviceType(manufacturer=manufacturers[0], model='Model 3', slug='model-3'), + ) + DeviceType.objects.bulk_create(device_types) + + inventory_item_roles = ( + InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'), + InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'), + InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'), + ) + InventoryItemRole.objects.bulk_create(inventory_item_roles) + + inventory_item_templates = ( + InventoryItemTemplate(device_type=device_types[0], name='Inventory Item 1', label='A', role=inventory_item_roles[0], manufacturer=manufacturers[0], part_id='1001'), + InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 2', label='B', role=inventory_item_roles[1], manufacturer=manufacturers[1], part_id='1002'), + InventoryItemTemplate(device_type=device_types[2], name='Inventory Item 3', label='C', role=inventory_item_roles[2], manufacturer=manufacturers[2], part_id='1003'), + ) + for item in inventory_item_templates: + item.save() + + child_inventory_item_templates = ( + InventoryItemTemplate(device_type=device_types[0], name='Inventory Item 1A', parent=inventory_item_templates[0]), + InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 2A', parent=inventory_item_templates[1]), + InventoryItemTemplate(device_type=device_types[2], name='Inventory Item 3A', parent=inventory_item_templates[2]), + ) + for item in child_inventory_item_templates: + item.save() + + def test_name(self): + params = {'name': ['Inventory Item 1', 'Inventory Item 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_devicetype_id(self): + device_types = DeviceType.objects.all()[:2] + params = {'devicetype_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_part_id(self): + params = {'part_id': ['1001', '1002']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent_id(self): + parent_items = InventoryItemTemplate.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_items[0].pk, parent_items[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_role(self): + roles = InventoryItemRole.objects.all()[:2] + params = {'role_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'role': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_manufacturer(self): + manufacturers = Manufacturer.objects.all()[:2] + params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceRole.objects.all() filterset = DeviceRoleFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8f077df92..8f7cb606b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -580,6 +580,20 @@ class DeviceTypeTestCase( url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_inventoryitems(self): + devicetype = DeviceType.objects.first() + inventory_items = ( + DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 2'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 3'), + ) + for inventory_item in inventory_items: + inventory_item.save() + + url = reverse('dcim:devicetype_inventoryitems', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_import_objects(self): """ @@ -659,6 +673,13 @@ device-bays: - name: Device Bay 1 - name: Device Bay 2 - name: Device Bay 3 +inventory-items: + - name: Inventory Item 1 + manufacturer: Generic + - name: Inventory Item 2 + manufacturer: Generic + - name: Inventory Item 3 + manufacturer: Generic """ # Create the manufacturer @@ -677,6 +698,7 @@ device-bays: 'dcim.add_rearporttemplate', 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', + 'dcim.add_inventoryitemtemplate', ) form_data = { @@ -729,13 +751,17 @@ device-bays: self.assertEqual(fp1.rear_port_position, 1) self.assertEqual(device_type.modulebaytemplates.count(), 3) - db1 = ModuleBayTemplate.objects.first() - self.assertEqual(db1.name, 'Module Bay 1') + mb1 = ModuleBayTemplate.objects.first() + self.assertEqual(mb1.name, 'Module Bay 1') self.assertEqual(device_type.devicebaytemplates.count(), 3) db1 = DeviceBayTemplate.objects.first() self.assertEqual(db1.name, 'Device Bay 1') + self.assertEqual(device_type.inventoryitemtemplates.count(), 3) + ii1 = InventoryItemTemplate.objects.first() + self.assertEqual(ii1.name, 'Inventory Item 1') + def test_export_objects(self): url = reverse('dcim:devicetype_list') self.add_permissions('dcim.view_devicetype') @@ -1393,6 +1419,48 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas } +class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = InventoryItemTemplate + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + devicetypes = ( + DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + inventory_item_templates = ( + InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]), + ) + for item in inventory_item_templates: + item.save() + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Inventory Item Template X', + 'manufacturer': manufacturers[1].pk, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Inventory Item Template [4-6]', + 'manufacturer': manufacturers[1].pk, + } + + cls.bulk_edit_data = { + 'description': 'Foo bar', + } + + class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = DeviceRole diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index d45ce7577..bfd6fecad 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -115,6 +115,7 @@ urlpatterns = [ 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//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), @@ -203,7 +204,7 @@ urlpatterns = [ path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), - # Device bay templates + # Module bay templates path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'), path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'), path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'), @@ -211,6 +212,14 @@ urlpatterns = [ path('module-bay-templates//edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'), path('module-bay-templates//delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'), + # Inventory item templates + path('inventory-item-templates/add/', views.InventoryItemTemplateCreateView.as_view(), name='inventoryitemtemplate_add'), + path('inventory-item-templates/edit/', views.InventoryItemTemplateBulkEditView.as_view(), name='inventoryitemtemplate_bulk_edit'), + path('inventory-item-templates/rename/', views.InventoryItemTemplateBulkRenameView.as_view(), name='inventoryitemtemplate_bulk_rename'), + path('inventory-item-templates/delete/', views.InventoryItemTemplateBulkDeleteView.as_view(), name='inventoryitemtemplate_bulk_delete'), + path('inventory-item-templates//edit/', views.InventoryItemTemplateEditView.as_view(), name='inventoryitemtemplate_edit'), + path('inventory-item-templates//delete/', views.InventoryItemTemplateDeleteView.as_view(), name='inventoryitemtemplate_delete'), + # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4e63c0e76..54d100f41 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -869,6 +869,13 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_devicebays' +class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): + child_model = InventoryItemTemplate + table = tables.InventoryItemTemplateTable + filterset = filtersets.InventoryItemTemplateFilterSet + viewname = 'dcim:devicetype_inventoryitems' + + class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm @@ -890,6 +897,7 @@ class DeviceTypeImportView(generic.ObjectImportView): 'dcim.add_rearporttemplate', 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', + 'dcim.add_inventoryitemtemplate', ] queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm @@ -903,6 +911,7 @@ class DeviceTypeImportView(generic.ObjectImportView): ('front-ports', forms.FrontPortTemplateImportForm), ('module-bays', forms.ModuleBayTemplateImportForm), ('device-bays', forms.DeviceBayTemplateImportForm), + ('inventory-items', forms.InventoryItemTemplateImportForm), )) def prep_related_object_data(self, parent, data): @@ -1362,6 +1371,51 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceBayTemplateTable +# +# Inventory item templates +# + +class InventoryItemTemplateCreateView(generic.ComponentCreateView): + queryset = InventoryItemTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm + model_form = forms.InventoryItemTemplateForm + + def alter_object(self, instance, request): + # Set component (if any) + component_type = request.GET.get('component_type') + component_id = request.GET.get('component_id') + + if component_type and component_id: + content_type = get_object_or_404(ContentType, pk=component_type) + instance.component = get_object_or_404(content_type.model_class(), pk=component_id) + + return instance + + +class InventoryItemTemplateEditView(generic.ObjectEditView): + queryset = InventoryItemTemplate.objects.all() + model_form = forms.InventoryItemTemplateForm + + +class InventoryItemTemplateDeleteView(generic.ObjectDeleteView): + queryset = InventoryItemTemplate.objects.all() + + +class InventoryItemTemplateBulkEditView(generic.BulkEditView): + queryset = InventoryItemTemplate.objects.all() + table = tables.InventoryItemTemplateTable + form = forms.InventoryItemTemplateBulkEditForm + + +class InventoryItemTemplateBulkRenameView(generic.BulkRenameView): + queryset = InventoryItemTemplate.objects.all() + + +class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = InventoryItemTemplate.objects.all() + table = tables.InventoryItemTemplateTable + + # # Device roles # diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index 9c0b08c19..e2bb72a74 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -44,6 +44,9 @@ {% if perms.dcim.add_devicebaytemplate %}

  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitemtemplate %} +
  • Inventory Items
  • + {% endif %} {% endif %} @@ -127,4 +130,12 @@ {% endif %} {% endwith %} + + {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %} + {% if active_tab == tab_name or inventoryitem_count %} + + {% endif %} + {% endwith %} {% endblock %} From 791cc093f478f987c1ed52fd6e9064003eb963e6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Dec 2021 16:30:44 -0500 Subject: [PATCH 058/271] Enable the association of inventory item templates with component templates --- netbox/dcim/forms/models.py | 2 +- netbox/dcim/tables/devicetypes.py | 22 +++++++++++++------ netbox/dcim/tables/template_code.py | 13 +++++++++++ netbox/dcim/views.py | 1 + netbox/netbox/views/generic/object_views.py | 13 ++--------- .../templates/dcim/inventoryitem_create.html | 16 +++++++------- 6 files changed, 40 insertions(+), 27 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 9fcea7661..65b7d46a8 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1092,7 +1092,7 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): ) component_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), - limit_choices_to=MODULAR_COMPONENT_MODELS, + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, required=False, widget=forms.HiddenInput ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 885fe69a4..525c69030 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -8,6 +8,7 @@ from dcim.models import ( from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) +from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( 'ConsolePortTemplateTable', @@ -114,7 +115,8 @@ class ComponentTemplateTable(BaseTable): class ConsolePortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=ConsolePortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -126,7 +128,8 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=ConsoleServerPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -138,7 +141,8 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=PowerPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -150,7 +154,8 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=PowerOutletTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -165,7 +170,8 @@ class InterfaceTemplateTable(ComponentTemplateTable): ) actions = ButtonsColumn( model=InterfaceTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -181,7 +187,8 @@ class FrontPortTemplateTable(ComponentTemplateTable): color = ColorColumn() actions = ButtonsColumn( model=FrontPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -194,7 +201,8 @@ class RearPortTemplateTable(ComponentTemplateTable): color = ColorColumn() actions = ButtonsColumn( model=RearPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 6b44c4b3f..2b6c02b82 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -93,6 +93,19 @@ LOCATION_ELEVATIONS = """ """ +# +# Device component templatebuttons +# + +MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ +{% load helpers %} +{% if perms.dcim.add_invnetoryitemtemplate %} + + + +{% endif %} +""" + # # Device component buttons # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 54d100f41..e64124539 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1379,6 +1379,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView): queryset = InventoryItemTemplate.objects.all() form = forms.DeviceTypeComponentCreateForm model_form = forms.InventoryItemTemplateForm + template_name = 'dcim/inventoryitem_create.html' def alter_object(self, instance, request): # Set component (if any) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 5895a7a6e..607501a9b 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -737,6 +737,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View 'return_url': self.get_return_url(request), }) + # TODO: Refactor this method for clarity & better error reporting def validate_form(self, request, form): """ Validate form values and set errors on the form object as they are detected. If @@ -763,17 +764,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View if component_form.is_valid(): new_components.append(component_form) - else: - for field, errors in component_form.errors.as_data().items(): - # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form - if field == 'name': - field = 'name_pattern' - elif field == 'label': - field = 'label_pattern' - for e in errors: - form.add_error(field, '{}: {}'.format(name, ', '.join(e))) - - if not form.errors: + if not form.errors and not component_form.errors: try: with transaction.atomic(): # Create the new components diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html index ef20a2188..43c5b39fa 100644 --- a/netbox/templates/dcim/inventoryitem_create.html +++ b/netbox/templates/dcim/inventoryitem_create.html @@ -1,17 +1,17 @@ -{% extends 'dcim/component_create.html' %} +{% extends 'generic/object_edit.html' %} {% load helpers %} +{% load form_helpers %} {% block form %} + {% render_form replication_form %} {% if obj.component %}
    - -
    -
    - {{ obj.component }} + +
    +
    -
    {% endif %} {{ block.super }} From 1edf80db8eead41037ead4960634c6ea28e2078e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Dec 2021 16:40:03 -0500 Subject: [PATCH 059/271] Changelog & documentation for #8118 --- docs/models/dcim/devicetype.md | 4 ++-- docs/models/dcim/inventoryitem.md | 6 +++--- docs/models/dcim/inventoryitemtemplate.md | 3 +++ docs/release-notes/version-3.2.md | 7 +++++++ 4 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 docs/models/dcim/inventoryitemtemplate.md diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index b919465c8..cf42185f4 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -4,13 +4,13 @@ A device type represents a particular make and model of hardware that exists in Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively. -Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: +Some devices house child devices which share physical resources, like space and power, but which function independently. A common example of this is blade server chassis. Each device type is designated as one of the following: * A parent device (which has device bays) * A child device (which must be installed within a device bay) * Neither !!! note - This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device. A device type may optionally specify an airflow direction, such as front-to-rear, rear-to-front, or passive. Airflow direction may also be set separately per device. If it is not defined for a device at the time of its creation, it will inherit the airflow setting of its device type. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index f98371833..fbd3172bb 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,7 +1,7 @@ # Inventory Items -Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes. +Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. -Each inventory item can be assigned a functional role, manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox). +Each inventory item can be assigned a functional role, manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside NetBox). -Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. +Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md new file mode 100644 index 000000000..3167ed4ab --- /dev/null +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -0,0 +1,3 @@ +# Inventory Item Templates + +A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e0db0b13b..d2702cfda 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -42,6 +42,12 @@ FIELD_CHOICES = { } ``` +#### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) + +Inventory items can now be templatized on a device type similar to the other component types. This enables users to better pre-model fixed hardware components. + +Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other components. These relationships will be mirrored when instantiating inventory items on a newly-created device. + ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation @@ -62,6 +68,7 @@ FIELD_CHOICES = { * Added the following endpoints: * `/api/dcim/inventory-item-roles/` + * `/api/dcim/inventory-item-templates/` * `/api/dcim/modules/` * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` From 9f53497e391f29961bda990eda88a8a943e5c96f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Dec 2021 20:28:12 -0500 Subject: [PATCH 060/271] Clean up & expand button color choices --- netbox/extras/choices.py | 33 +++++--------------------------- netbox/extras/models/models.py | 2 +- netbox/utilities/choices.py | 35 +++++++++++++++++++++++----------- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index c01e64831..cf64bc005 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -1,4 +1,4 @@ -from utilities.choices import ChoiceSet +from utilities.choices import ButtonColorChoices, ChoiceSet # @@ -47,36 +47,13 @@ class CustomFieldFilterLogicChoices(ChoiceSet): # CustomLinks # -class CustomLinkButtonClassChoices(ChoiceSet): +class CustomLinkButtonClassChoices(ButtonColorChoices): - CLASS_DEFAULT = 'outline-dark' - CLASS_LINK = 'ghost-dark' - CLASS_BLUE = 'blue' - CLASS_INDIGO = 'indigo' - CLASS_PURPLE = 'purple' - CLASS_PINK = 'pink' - CLASS_RED = 'red' - CLASS_ORANGE = 'orange' - CLASS_YELLOW = 'yellow' - CLASS_GREEN = 'green' - CLASS_TEAL = 'teal' - CLASS_CYAN = 'cyan' - CLASS_GRAY = 'secondary' + LINK = 'ghost-dark' CHOICES = ( - (CLASS_DEFAULT, 'Default'), - (CLASS_LINK, 'Link'), - (CLASS_BLUE, 'Blue'), - (CLASS_INDIGO, 'Indigo'), - (CLASS_PURPLE, 'Purple'), - (CLASS_PINK, 'Pink'), - (CLASS_RED, 'Red'), - (CLASS_ORANGE, 'Orange'), - (CLASS_YELLOW, 'Yellow'), - (CLASS_GREEN, 'Green'), - (CLASS_TEAL, 'Teal'), - (CLASS_CYAN, 'Cyan'), - (CLASS_GRAY, 'Gray'), + *ButtonColorChoices.CHOICES, + (LINK, 'Link'), ) # diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 5471f4d67..ac3a23410 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -212,7 +212,7 @@ class CustomLink(ChangeLoggedModel): button_class = models.CharField( max_length=30, choices=CustomLinkButtonClassChoices, - default=CustomLinkButtonClassChoices.CLASS_DEFAULT, + default=CustomLinkButtonClassChoices.DEFAULT, help_text="The class of the first link in a group will be used for the dropdown button" ) new_window = models.BooleanField( diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 712783448..b500f5b9d 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -161,21 +161,34 @@ class ButtonColorChoices(ChoiceSet): Map standard button color choices to Bootstrap 3 button classes """ DEFAULT = 'outline-dark' - BLUE = 'primary' - CYAN = 'info' - GREEN = 'success' - RED = 'danger' - YELLOW = 'warning' - GREY = 'secondary' - BLACK = 'dark' + BLUE = 'blue' + INDIGO = 'indigo' + PURPLE = 'purple' + PINK = 'pink' + RED = 'red' + ORANGE = 'orange' + YELLOW = 'yellow' + GREEN = 'green' + TEAL = 'teal' + CYAN = 'cyan' + GRAY = 'gray' + GREY = 'gray' # Backward compatability for <3.2 + BLACK = 'black' + WHITE = 'white' CHOICES = ( (DEFAULT, 'Default'), (BLUE, 'Blue'), - (CYAN, 'Cyan'), - (GREEN, 'Green'), + (INDIGO, 'Indigo'), + (PURPLE, 'Purple'), + (PINK, 'Pink'), (RED, 'Red'), + (ORANGE, 'Orange'), (YELLOW, 'Yellow'), - (GREY, 'Grey'), - (BLACK, 'Black') + (GREEN, 'Green'), + (TEAL, 'Teal'), + (CYAN, 'Cyan'), + (GRAY, 'Gray'), + (BLACK, 'Black'), + (WHITE, 'White'), ) From 8e699617448757f1b10a0befce7290b678f50494 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 08:15:43 -0500 Subject: [PATCH 061/271] Fix CustomLinkButtonClassChoices references in tests --- netbox/extras/tests/test_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 67abcf543..34d5cb67e 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -68,7 +68,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Custom Link X', 'content_type': site_ct.pk, 'weight': 100, - 'button_class': CustomLinkButtonClassChoices.CLASS_DEFAULT, + 'button_class': CustomLinkButtonClassChoices.DEFAULT, 'link_text': 'Link X', 'link_url': 'http://example.com/?x' } @@ -81,7 +81,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.bulk_edit_data = { - 'button_class': CustomLinkButtonClassChoices.CLASS_CYAN, + 'button_class': CustomLinkButtonClassChoices.CYAN, 'weight': 200, } From fa1e28e860c4bdb3e585a968bd248a2ac666e1f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Dec 2021 17:03:41 -0500 Subject: [PATCH 062/271] Initial work on #7006 --- netbox/extras/api/customfields.py | 15 +- netbox/extras/choices.py | 2 + netbox/extras/forms/customfields.py | 13 +- netbox/extras/forms/models.py | 2 +- .../migrations/0068_custom_object_field.py | 18 ++ netbox/extras/models/customfields.py | 41 +++- netbox/extras/tests/test_customfields.py | 231 +++++++++--------- netbox/extras/tests/test_forms.py | 14 +- netbox/netbox/models.py | 15 +- .../templates/inc/panels/custom_fields.html | 2 + 10 files changed, 224 insertions(+), 129 deletions(-) create mode 100644 netbox/extras/migrations/0068_custom_object_field.py diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5cb1fc276..f2f4b69a6 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.fields import Field +from extras.choices import CustomFieldTypeChoices from extras.models import CustomField @@ -44,9 +45,17 @@ class CustomFieldsDataField(Field): return self._custom_fields def to_representation(self, obj): - return { - cf.name: obj.get(cf.name) for cf in self._get_custom_fields() - } + # TODO: Fix circular import + from utilities.api import get_serializer_for_model + data = {} + for cf in self._get_custom_fields(): + value = cf.deserialize(obj.get(cf.name)) + if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + value = serializer(value, context=self.parent.context).data + data[cf.name] = value + + return data def to_internal_value(self, data): # If updating an existing instance, start with existing custom_field_data diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index cf64bc005..5c18f2705 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -16,6 +16,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_JSON = 'json' TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' + TYPE_OBJECT = 'object' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), + (TYPE_OBJECT, 'NetBox object'), ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index d58e6ce65..bd28a30e7 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -20,7 +20,7 @@ class CustomFieldsMixin: Extend a Form to include custom field support. """ def __init__(self, *args, **kwargs): - self.custom_fields = [] + self.custom_fields = {} super().__init__(*args, **kwargs) @@ -49,7 +49,7 @@ class CustomFieldsMixin: self.fields[field_name] = self._get_form_field(customfield) # Annotate the field in the list of CustomField form fields - self.custom_fields.append(field_name) + self.custom_fields[field_name] = customfield class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): @@ -70,12 +70,15 @@ class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): def clean(self): # Save custom field data on instance - for cf_name in self.custom_fields: + for cf_name, customfield in self.custom_fields.items(): key = cf_name[3:] # Strip "cf_" from field name value = self.cleaned_data.get(cf_name) - empty_values = self.fields[cf_name].empty_values + # Convert "empty" values to null - self.instance.custom_field_data[key] = value if value not in empty_values else None + if value in self.fields[cf_name].empty_values: + self.instance.custom_field_data[key] = None + else: + self.instance.custom_field_data[key] = customfield.serialize(value) return super().clean() diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index d75214722..55e58a7f2 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -35,7 +35,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): model = CustomField fields = '__all__' fieldsets = ( - ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), + ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), ('Assigned Models', ('content_types',)), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), diff --git a/netbox/extras/migrations/0068_custom_object_field.py b/netbox/extras/migrations/0068_custom_object_field.py new file mode 100644 index 000000000..0fa50a84d --- /dev/null +++ b/netbox/extras/migrations/0068_custom_object_field.py @@ -0,0 +1,18 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0067_configcontext_cluster_types'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='object_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 8c817ad33..fa65cbdee 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, + CSVChoiceField, DatePicker, DynamicModelChoiceField, LaxURLField, StaticSelectMultiple, StaticSelect, + add_blank_choice, ) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -50,8 +51,17 @@ class CustomField(ChangeLoggedModel): type = models.CharField( max_length=50, choices=CustomFieldTypeChoices, - default=CustomFieldTypeChoices.TYPE_TEXT + default=CustomFieldTypeChoices.TYPE_TEXT, + help_text='The type of data this custom field holds' ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + blank=True, + null=True, + help_text='The type of NetBox object this field maps to (for object fields)' + ) + name = models.CharField( max_length=50, unique=True, @@ -122,7 +132,6 @@ class CustomField(ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) - objects = CustomFieldManager() class Meta: @@ -234,6 +243,23 @@ class CustomField(ChangeLoggedModel): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) + def serialize(self, value): + """ + Prepare a value for storage as JSON data. + """ + if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + return value.pk + return value + + def deserialize(self, value): + """ + Convert JSON data to a Python object suitable for the field type. + """ + if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + model = self.object_type.model_class() + return model.objects.filter(pk=value).first() + return value + def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. @@ -300,6 +326,15 @@ class CustomField(ChangeLoggedModel): elif self.type == CustomFieldTypeChoices.TYPE_JSON: field = forms.JSONField(required=required, initial=initial) + # Object + elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: + model = self.object_type.model_class() + field = DynamicModelChoiceField( + queryset=model.objects.all(), + required=required, + initial=initial + ) + # Text else: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index fdabe0fcf..df803ce1b 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -8,6 +8,7 @@ from dcim.forms import SiteCSVForm from dcim.models import Site, Rack from extras.choices import * from extras.models import CustomField +from ipam.models import VLAN from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -201,76 +202,67 @@ class CustomFieldAPITest(APITestCase): def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Site) - # Text custom field - cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') - cls.cf_text.save() - cls.cf_text.content_types.set([content_type]) + # Create some VLANs + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + ) + VLAN.objects.bulk_create(vlans) - # Long text custom field - cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') - cls.cf_longtext.save() - cls.cf_longtext.content_types.set([content_type]) + custom_fields = ( + CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), + CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), + CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), + CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), + CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), + CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'), + CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=( + 'Foo', 'Bar', 'Baz' + )), + CustomField( + type=CustomFieldTypeChoices.TYPE_OBJECT, + name='object_field', + object_type=ContentType.objects.get_for_model(VLAN), + default=vlans[0].pk, + ), + ) + for cf in custom_fields: + cf.save() + cf.content_types.set([content_type]) - # Integer custom field - cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) - cls.cf_integer.save() - cls.cf_integer.content_types.set([content_type]) - - # Boolean custom field - cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False) - cls.cf_boolean.save() - cls.cf_boolean.content_types.set([content_type]) - - # Date custom field - cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01') - cls.cf_date.save() - cls.cf_date.content_types.set([content_type]) - - # URL custom field - cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1') - cls.cf_url.save() - cls.cf_url.content_types.set([content_type]) - - # JSON custom field - cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}') - cls.cf_json.save() - cls.cf_json.content_types.set([content_type]) - - # Select custom field - cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) - cls.cf_select.default = 'Foo' - cls.cf_select.save() - cls.cf_select.content_types.set([content_type]) - - # Create some sites - cls.sites = ( + # Create some sites *after* creating the custom fields. This ensures that + # default values are not set for the assigned objects. + sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), ) - Site.objects.bulk_create(cls.sites) + Site.objects.bulk_create(sites) # Assign custom field values for site 2 - cls.sites[1].custom_field_data = { - cls.cf_text.name: 'bar', - cls.cf_longtext.name: 'DEF', - cls.cf_integer.name: 456, - cls.cf_boolean.name: True, - cls.cf_date.name: '2020-01-02', - cls.cf_url.name: 'http://example.com/2', - cls.cf_json.name: '{"foo": 1, "bar": 2}', - cls.cf_select.name: 'Bar', + sites[1].custom_field_data = { + custom_fields[0].name: 'bar', + custom_fields[1].name: 'DEF', + custom_fields[2].name: 456, + custom_fields[3].name: True, + custom_fields[4].name: '2020-01-02', + custom_fields[5].name: 'http://example.com/2', + custom_fields[6].name: '{"foo": 1, "bar": 2}', + custom_fields[7].name: 'Bar', + custom_fields[8].name: vlans[1].pk, } - cls.sites[1].save() + sites[1].save() def test_get_single_object_without_custom_field_data(self): """ Validate that custom fields are present on an object even if it has no values defined. """ - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk}) + site1 = Site.objects.get(name='Site 1') + url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[0].name) + self.assertEqual(response.data['name'], site1.name) self.assertEqual(response.data['custom_fields'], { 'text_field': None, 'longtext_field': None, @@ -280,18 +272,20 @@ class CustomFieldAPITest(APITestCase): 'url_field': None, 'json_field': None, 'choice_field': None, + 'object_field': None, }) def test_get_single_object_with_custom_field_data(self): """ Validate that custom fields are present and correctly set for an object with values defined. """ - site2_cfvs = self.sites[1].custom_field_data - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + site2_cfvs = site2.custom_field_data + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[1].name) + self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) @@ -300,11 +294,15 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) + self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) def test_create_single_object_with_defaults(self): """ Create a new site with no specified custom field values and check that it received the default values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = { 'name': 'Site 3', 'slug': 'site-3', @@ -317,25 +315,27 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) def test_create_single_object_with_values(self): """ @@ -353,6 +353,7 @@ class CustomFieldAPITest(APITestCase): 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', + 'object_field': VLAN.objects.get(vid=2).pk, }, } url = reverse('dcim-api:site-list') @@ -372,6 +373,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) + self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -383,12 +385,16 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) def test_create_multiple_objects_with_defaults(self): """ - Create three news sites with no specified custom field values and check that each received + Create three new sites with no specified custom field values and check that each received the default custom field values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = ( { 'name': 'Site 3', @@ -414,25 +420,27 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) def test_create_multiple_objects_with_values(self): """ @@ -447,6 +455,7 @@ class CustomFieldAPITest(APITestCase): 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', + 'object_field': VLAN.objects.get(vid=2).pk, } data = ( { @@ -501,15 +510,15 @@ class CustomFieldAPITest(APITestCase): Update an object with existing custom field values. Ensure that only the updated custom field values are modified. """ - site = self.sites[1] - original_cfvs = {**site.custom_field_data} + site2 = Site.objects.get(name='Site 2') + original_cfvs = {**site2.custom_field_data} data = { 'custom_fields': { 'text_field': 'ABCD', 'number_field': 1234, }, } - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') response = self.client.patch(url, data, format='json', **self.header) @@ -527,23 +536,25 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) # Validate database data - site.refresh_from_db() - self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field']) - self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) - self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) - self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) - self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) - self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) + site2.refresh_from_db() + self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) + self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field']) + self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) + self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) + self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) + self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field']) def test_minimum_maximum_values_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_integer.validation_minimum = 10 - self.cf_integer.validation_maximum = 20 - self.cf_integer.save() + cf_integer = CustomField.objects.get(name='number_field') + cf_integer.validation_minimum = 10 + cf_integer.validation_maximum = 20 + cf_integer.save() data = {'custom_fields': {'number_field': 9}} response = self.client.patch(url, data, format='json', **self.header) @@ -558,11 +569,13 @@ class CustomFieldAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) def test_regex_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters - self.cf_text.save() + cf_text = CustomField.objects.get(name='text_field') + cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters + cf_text.save() data = {'custom_fields': {'text_field': 'ABC123'}} response = self.client.patch(url, data, format='json', **self.header) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cf28a46e7..e8b16d7ab 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -38,10 +38,20 @@ class CustomFieldModelFormTest(TestCase): cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) cf_select.content_types.set([obj_type]) - cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=CHOICES) + cf_multiselect = CustomField.objects.create( + name='multiselect', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=CHOICES + ) cf_multiselect.content_types.set([obj_type]) + cf_object = CustomField.objects.create( + name='object', + type=CustomFieldTypeChoices.TYPE_OBJECT, + object_type=ContentType.objects.get_for_model(Site) + ) + cf_object.content_types.set([obj_type]) + def test_empty_values(self): """ Test that empty custom field values are stored as null diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 91240ee90..3e6ebd8b2 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -1,5 +1,4 @@ import logging -from collections import OrderedDict from django.contrib.contenttypes.fields import GenericRelation from django.core.serializers.json import DjangoJSONEncoder @@ -99,16 +98,20 @@ class CustomFieldsMixin(models.Model): """ from extras.models import CustomField - fields = CustomField.objects.get_for_model(self) - return OrderedDict([ - (field, self.custom_field_data.get(field.name)) for field in fields - ]) + data = {} + for field in CustomField.objects.get_for_model(self): + value = self.custom_field_data.get(field.name) + data[field] = field.deserialize(value) + + return data def clean(self): super().clean() from extras.models import CustomField - custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} + custom_fields = { + cf.name: cf for cf in CustomField.objects.get_for_model(self) + } # Validate all field values for field_name, value in self.custom_field_data.items(): diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index b48a43f1c..46636504e 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -24,6 +24,8 @@
    {{ value|render_json }}
    {% elif field.type == 'multiselect' and value %} {{ value|join:", " }} + {% elif field.type == 'object' and value %} + {{ value }} {% elif value is not None %} {{ value }} {% elif field.required %} From 954d81147e7d65ad099fc122ab2ea790b0eb0e0b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 4 Jan 2022 17:07:37 -0500 Subject: [PATCH 063/271] Reindex migrations --- ...{0068_custom_object_field.py => 0069_custom_object_field.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/extras/migrations/{0068_custom_object_field.py => 0069_custom_object_field.py} (89%) diff --git a/netbox/extras/migrations/0068_custom_object_field.py b/netbox/extras/migrations/0069_custom_object_field.py similarity index 89% rename from netbox/extras/migrations/0068_custom_object_field.py rename to netbox/extras/migrations/0069_custom_object_field.py index 0fa50a84d..720e21edc 100644 --- a/netbox/extras/migrations/0068_custom_object_field.py +++ b/netbox/extras/migrations/0069_custom_object_field.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0067_configcontext_cluster_types'), + ('extras', '0068_configcontext_cluster_types'), ] operations = [ From 88ac0f5d3409d0727aef5518c3632cd6b9383732 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 5 Jan 2022 11:31:00 -0600 Subject: [PATCH 064/271] Work on #6221 - Make templatetags safe for consumption when using plugins and update ButtonColumn to use viewname helper. --- netbox/utilities/tables.py | 7 ++++--- netbox/utilities/templatetags/helpers.py | 9 ++++++--- netbox/utilities/utils.py | 10 ++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 9000af110..82f25ebaf 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -216,18 +216,19 @@ class ButtonsColumn(tables.TemplateColumn): attrs = {'td': {'class': 'text-end text-nowrap noprint'}} # Note that braces are escaped to allow for string formatting prior to template rendering template_code = """ + {{% load helpers %}} {{% if "changelog" in buttons %}} - + {{% endif %}} {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - + {{% endif %}} {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} - + {{% endif %}} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 0e45cb581..be7dc97d1 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -16,9 +16,10 @@ from django.utils.safestring import mark_safe from markdown import markdown from netbox.config import get_config +from netbox.settings import PLUGINS from utilities.forms import get_selected_values, TableConfigForm from utilities.markdown import StrikethroughExtension -from utilities.utils import foreground_color +from utilities.utils import foreground_color, resolve_namespace register = template.Library() @@ -115,7 +116,8 @@ def viewname(model, action): """ Return the view name for the given model and action. Does not perform any validation. """ - return f'{model._meta.app_label}:{model._meta.model_name}_{action}' + namespace = resolve_namespace(model) + return f'{namespace}:{model._meta.model_name}_{action}' @register.filter() @@ -123,7 +125,8 @@ def validated_viewname(model, action): """ Return the view name for the given model and action if valid, or None if invalid. """ - viewname = f'{model._meta.app_label}:{model._meta.model_name}_{action}' + namespace = resolve_namespace(model) + viewname = f'{namespace}:{model._meta.model_name}_{action}' try: # Validate and return the view name. We don't return the actual URL yet because many of the templates # are written to pass a name to {% url %}. diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 444b87523..bf2b1638c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -13,10 +13,20 @@ from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices +from extras.plugins import PluginConfig from extras.utils import is_taggable from utilities.constants import HTTP_REQUEST_META_SAFE_COPY +def resolve_namespace(instance): + """ + Get the appropriate namepsace for the app based on whether it is a Plugin or base application + """ + if isinstance(instance._meta.app_config, PluginConfig): + return f'plugins:{instance._meta.app_label}' + return f'{instance._meta.app_label}' + + def csv_format(data): """ Encapsulate any data which contains a comma within double quotes. From 271b7adeb830eaafa6bb117c49aee3ceff7b1adc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 17:05:54 -0500 Subject: [PATCH 065/271] Extend to support the assignment of multiple objects per field --- netbox/extras/api/customfields.py | 3 ++ netbox/extras/choices.py | 4 +- netbox/extras/models/customfields.py | 27 +++++++++--- netbox/extras/tests/test_customfields.py | 42 +++++++++++++++++++ .../templates/inc/panels/custom_fields.html | 16 +++++-- 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index f2f4b69a6..fd6e1f550 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -53,6 +53,9 @@ class CustomFieldsDataField(Field): if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') value = serializer(value, context=self.parent.context).data + elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + value = serializer(value, many=True, context=self.parent.context).data data[cf.name] = value return data diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 5c18f2705..0632c2b1f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -17,6 +17,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' TYPE_OBJECT = 'object' + TYPE_MULTIOBJECT = 'multiobject' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -28,7 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), - (TYPE_OBJECT, 'NetBox object'), + (TYPE_OBJECT, 'Object'), + (TYPE_MULTIOBJECT, 'Multiple objects'), ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index fa65cbdee..99c483857 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -16,8 +16,8 @@ from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, DynamicModelChoiceField, LaxURLField, StaticSelectMultiple, StaticSelect, - add_blank_choice, + CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField, + StaticSelectMultiple, StaticSelect, add_blank_choice, ) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -61,7 +61,6 @@ class CustomField(ChangeLoggedModel): null=True, help_text='The type of NetBox object this field maps to (for object fields)' ) - name = models.CharField( max_length=50, unique=True, @@ -247,17 +246,26 @@ class CustomField(ChangeLoggedModel): """ Prepare a value for storage as JSON data. """ - if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + if value is None: + return value + if self.type == CustomFieldTypeChoices.TYPE_OBJECT: return value.pk + if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + return [obj.pk for obj in value] return value def deserialize(self, value): """ Convert JSON data to a Python object suitable for the field type. """ - if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None: + if value is None: + return value + if self.type == CustomFieldTypeChoices.TYPE_OBJECT: model = self.object_type.model_class() return model.objects.filter(pk=value).first() + if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + model = self.object_type.model_class() + return model.objects.filter(pk__in=value) return value def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): @@ -335,6 +343,15 @@ class CustomField(ChangeLoggedModel): initial=initial ) + # Multiple objects + elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + model = self.object_type.model_class() + field = DynamicModelMultipleChoiceField( + queryset=model.objects.all(), + required=required, + initial=initial + ) + # Text else: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index df803ce1b..657c597f2 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -206,6 +206,9 @@ class CustomFieldAPITest(APITestCase): vlans = ( VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + VLAN(name='VLAN 4', vid=4), + VLAN(name='VLAN 5', vid=5), ) VLAN.objects.bulk_create(vlans) @@ -226,6 +229,12 @@ class CustomFieldAPITest(APITestCase): object_type=ContentType.objects.get_for_model(VLAN), default=vlans[0].pk, ), + CustomField( + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + name='multiobject_field', + object_type=ContentType.objects.get_for_model(VLAN), + default=[vlans[0].pk, vlans[1].pk], + ), ) for cf in custom_fields: cf.save() @@ -250,6 +259,7 @@ class CustomFieldAPITest(APITestCase): custom_fields[6].name: '{"foo": 1, "bar": 2}', custom_fields[7].name: 'Bar', custom_fields[8].name: vlans[1].pk, + custom_fields[9].name: [vlans[2].pk, vlans[3].pk], } sites[1].save() @@ -273,6 +283,7 @@ class CustomFieldAPITest(APITestCase): 'json_field': None, 'choice_field': None, 'object_field': None, + 'multiobject_field': None, }) def test_get_single_object_with_custom_field_data(self): @@ -295,6 +306,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) + self.assertEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + site2_cfvs['multiobject_field'] + ) def test_create_single_object_with_defaults(self): """ @@ -324,6 +339,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) + self.assertEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + cf_defaults['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -336,6 +355,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) def test_create_single_object_with_values(self): """ @@ -354,6 +374,7 @@ class CustomFieldAPITest(APITestCase): 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', 'object_field': VLAN.objects.get(vid=2).pk, + 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), }, } url = reverse('dcim-api:site-list') @@ -374,6 +395,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['json_field'], data_cf['json_field']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + data_cf['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -386,6 +411,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field']) def test_create_multiple_objects_with_defaults(self): """ @@ -429,6 +455,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + cf_defaults['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -441,6 +471,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) def test_create_multiple_objects_with_values(self): """ @@ -456,6 +487,7 @@ class CustomFieldAPITest(APITestCase): 'json_field': '{"foo": 1, "bar": 2}', 'choice_field': 'Bar', 'object_field': VLAN.objects.get(vid=2).pk, + 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), } data = ( { @@ -493,6 +525,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + custom_field_data['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -504,6 +540,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field']) self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field']) def test_update_single_object_with_values(self): """ @@ -534,6 +571,10 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + original_cfvs['multiobject_field'] + ) # Validate database data site2.refresh_from_db() @@ -545,6 +586,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field']) + self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) def test_minimum_maximum_values_validation(self): site2 = Site.objects.get(name='Site 2') diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 46636504e..c8838fa80 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -3,14 +3,14 @@ {% with custom_fields=object.get_custom_fields %} {% if custom_fields %}
    -
    - Custom Fields -
    +
    Custom Fields
    {% for field, value in custom_fields.items %} - + + + + +
    {{ field }} + {{ field }} + {% if field.type == 'longtext' and value %} {{ value|render_markdown }} @@ -26,6 +26,14 @@ {{ value|join:", " }} {% elif field.type == 'object' and value %} {{ value }} + {% elif field.type == 'multiobject' and value %} + {% if value %} +
      + {% for obj in value %} +
    • {{ obj }}
    • + {% endfor %} +
    + {% endif %} {% elif value is not None %} {{ value }} {% elif field.required %} From 85c06372ffe04f0fbb4ac6c05394bc1f819785d0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 21:04:44 -0500 Subject: [PATCH 066/271] Fix bulk editing for custom object fields --- netbox/netbox/views/generic/bulk_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 6f21a6879..d76bc598d 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -285,7 +285,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return get_permission_for_model(self.queryset.model, 'change') def _update_objects(self, form, request): - custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] + custom_fields = getattr(form, 'custom_fields', []) standard_fields = [ field for field in form.fields if field not in custom_fields + ['pk'] ] @@ -327,7 +327,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if name in form.nullable_fields and name in nullified_fields: obj.custom_field_data[name] = None elif name in form.changed_data: - obj.custom_field_data[name] = form.cleaned_data[name] + obj.custom_field_data[name] = form.fields[name].prepare_value(form.cleaned_data[name]) obj.full_clean() obj.save() From 7aa1fabbd7f4117242d2bdc564fd4dee31182edb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 21:21:23 -0500 Subject: [PATCH 067/271] Fix tests --- netbox/extras/models/customfields.py | 2 +- netbox/extras/tests/test_forms.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 99c483857..851680d8e 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -251,7 +251,7 @@ class CustomField(ChangeLoggedModel): if self.type == CustomFieldTypeChoices.TYPE_OBJECT: return value.pk if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - return [obj.pk for obj in value] + return [obj.pk for obj in value] or None return value def deserialize(self, value): diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index e8b16d7ab..1ec50b7dd 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -52,6 +52,13 @@ class CustomFieldModelFormTest(TestCase): ) cf_object.content_types.set([obj_type]) + cf_multiobject = CustomField.objects.create( + name='multiobject', + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + object_type=ContentType.objects.get_for_model(Site) + ) + cf_multiobject.content_types.set([obj_type]) + def test_empty_values(self): """ Test that empty custom field values are stored as null From 1e80cc6db5dbbc7314519c2014d282f7dc3d85df Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 13:24:37 -0500 Subject: [PATCH 068/271] Clean up & extend custom field tests --- netbox/extras/tests/test_customfields.py | 382 ++++++++++++++++------- 1 file changed, 270 insertions(+), 112 deletions(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 657c597f2..9191c1c5b 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -15,7 +15,8 @@ from virtualization.models import VirtualMachine class CustomFieldTest(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): Site.objects.bulk_create([ Site(name='Site A', slug='site-a'), @@ -23,137 +24,294 @@ class CustomFieldTest(TestCase): Site(name='Site C', slug='site-c'), ]) - def test_simple_fields(self): - DATA = ( - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_TEXT, - }, - 'value': 'Foobar!', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_LONGTEXT, - }, - 'value': 'Text with **Markdown**', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - }, - 'value': 0, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - 'validation_minimum': 1, - 'validation_maximum': 100, - }, - 'value': 42, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - 'validation_minimum': -100, - 'validation_maximum': -1, - }, - 'value': -42, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, - }, - 'value': True, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, - }, - 'value': False, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_DATE, - }, - 'value': '2016-06-23', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_URL, - }, - 'value': 'http://example.com/', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_JSON, - }, - 'value': '{"foo": 1, "bar": 2}', - }, + cls.object_type = ContentType.objects.get_for_model(Site) + + def test_text_field(self): + value = 'Foobar!' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='text_field', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - obj_type = ContentType.objects.get_for_model(Site) + # 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) - for data in DATA: + # 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)) - # Create a custom field - cf = CustomField(name='my_field', required=False, **data['field']) - cf.save() - cf.content_types.set([obj_type]) + def test_longtext_field(self): + value = 'A' * 256 - # Check that the field has a null initial value - site = Site.objects.first() - self.assertIsNone(site.custom_field_data[cf.name]) + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='longtext_field', + type=CustomFieldTypeChoices.TYPE_LONGTEXT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Assign a value to the first Site - site.custom_field_data[cf.name] = data['value'] - site.save() + # 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) - # Retrieve the stored value - site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], data['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)) - # Delete the stored value - site.custom_field_data.pop(cf.name) - site.save() - site.refresh_from_db() - self.assertIsNone(site.custom_field_data.get(cf.name)) + def test_integer_field(self): - # Delete the custom field - cf.delete() + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='integer_field', + type=CustomFieldTypeChoices.TYPE_INTEGER, + 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, 0, -123456): + + # 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 + cf = CustomField.objects.create( + name='boolean_field', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (True, False): + + # 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_date_field(self): + value = '2016-06-23' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='date_field', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # 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_url_field(self): + value = 'http://example.com/' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='url_field', + type=CustomFieldTypeChoices.TYPE_URL, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # 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_json_field(self): + value = '{"foo": 1, "bar": 2}' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='json_field', + type=CustomFieldTypeChoices.TYPE_JSON, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # 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_select_field(self): - obj_type = ContentType.objects.get_for_model(Site) + CHOICES = ('Option A', 'Option B', 'Option C') + value = CHOICES[1] - # Create a custom field - cf = CustomField( + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='select_field', type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field', required=False, - choices=['Option A', 'Option B', 'Option C'] + choices=CHOICES ) - cf.save() - cf.content_types.set([obj_type]) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Check that the field has a null initial value - site = Site.objects.first() - self.assertIsNone(site.custom_field_data[cf.name]) + # 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) - # Assign a value to the first Site - site.custom_field_data[cf.name] = 'Option A' - site.save() + # 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)) - # Retrieve the stored value - site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], 'Option A') + def test_multiselect_field(self): + CHOICES = ['Option A', 'Option B', 'Option C'] + value = [CHOICES[1], CHOICES[2]] - # Delete the stored value - site.custom_field_data.pop(cf.name) - site.save() - site.refresh_from_db() - self.assertIsNone(site.custom_field_data.get(cf.name)) + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='multiselect_field', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + choices=CHOICES + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Delete the custom field - cf.delete() + # 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_object_field(self): + value = VLAN.objects.create(name='VLAN 1', vid=1).pk + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='object_field', + type=CustomFieldTypeChoices.TYPE_OBJECT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # 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_multiobject_field(self): + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + ) + VLAN.objects.bulk_create(vlans) + value = [vlan.pk for vlan in vlans] + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='object_field', + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # 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_rename_customfield(self): obj_type = ContentType.objects.get_for_model(Site) From bfc695434c8daf2642bee6e5fd2e400aecd31b9c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 13:43:40 -0500 Subject: [PATCH 069/271] Add object_type validation --- netbox/extras/models/customfields.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 851680d8e..00c68939d 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -242,6 +242,17 @@ class CustomField(ChangeLoggedModel): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) + # Object fields must define an object_type; other fields must not + if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): + if not self.object_type: + raise ValidationError({ + 'object_type': "Object fields must define an object type." + }) + elif self.object_type: + raise ValidationError({ + 'object_type': f"{self.get_type_display()} fields may not define an object type." + }) + def serialize(self, value): """ Prepare a value for storage as JSON data. From 3002382edc77ad8872940dd3e3b24bae0fea407b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 13:44:21 -0500 Subject: [PATCH 070/271] Documentation and changelog for #7006 --- docs/models/extras/customfield.md | 6 ++++++ docs/release-notes/version-3.2.md | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index e3462a6a7..da73816b6 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -19,6 +19,8 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * JSON: Arbitrary data stored in JSON format * Selection: A selection of one of several pre-defined custom choices * Multiple selection: A selection field which supports the assignment of multiple values +* Object: A single NetBox object of the type defined by `object_type` +* Multiple object: One or more NetBox objects of the type defined by `object_type` Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. @@ -41,3 +43,7 @@ NetBox supports limited custom validation for custom field values. Following are Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. + +### Custom Object Fields + +An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index d2702cfda..6240016cf 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -22,6 +22,12 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available- A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. +#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) + +Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. + +Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object. + #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. @@ -96,6 +102,8 @@ Inventory item templates can be arranged hierarchically within a device type, an * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields * extras.ConfigContext * Add `cluster_types` field +* extras.CustomField + * Added `object_type` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint * Added the `min_vid` and `max_vid` fields From 58f7eb319faad9f135afbd86e060eb2e2ebf574e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 6 Jan 2022 16:53:24 -0500 Subject: [PATCH 071/271] Initial work on #7679 --- netbox/circuits/tables.py | 4 +-- netbox/dcim/tables/devices.py | 8 +++--- netbox/dcim/tables/devicetypes.py | 5 ++-- netbox/dcim/tables/racks.py | 6 ++-- netbox/dcim/tables/sites.py | 7 +++-- netbox/extras/tables.py | 8 ++---- netbox/ipam/tables/fhrp.py | 7 ++--- netbox/ipam/tables/ip.py | 14 ++++----- netbox/ipam/tables/vlans.py | 12 +++++--- netbox/tenancy/tables.py | 13 ++++----- netbox/utilities/tables.py | 48 +++++++++++++++++++++++++++++++ netbox/virtualization/tables.py | 8 +++--- netbox/wireless/tables.py | 4 +-- 13 files changed, 96 insertions(+), 48 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 32c40f269..59ef073d3 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn +from utilities.tables import ActionsColumn, BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from .models import * @@ -88,7 +88,7 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ButtonsColumn(CircuitType) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = CircuitType diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0c3a5f6a1..21da569a7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -7,7 +7,7 @@ from dcim.models import ( ) from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, + ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import * @@ -94,7 +94,7 @@ class DeviceRoleTable(BaseTable): tags = TagColumn( url_name='dcim:devicerole_list' ) - actions = ButtonsColumn(DeviceRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = DeviceRole @@ -127,7 +127,7 @@ class PlatformTable(BaseTable): tags = TagColumn( url_name='dcim:platform_list' ) - actions = ButtonsColumn(Platform) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Platform @@ -839,7 +839,7 @@ class InventoryItemRoleTable(BaseTable): tags = TagColumn( url_name='dcim:inventoryitemrole_list' ) - actions = ButtonsColumn(InventoryItemRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItemRole diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 525c69030..df27a366f 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -6,7 +6,8 @@ from dcim.models import ( InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ToggleColumn, ) from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -48,7 +49,7 @@ class ManufacturerTable(BaseTable): tags = TagColumn( url_name='dcim:manufacturer_list' ) - actions = ButtonsColumn(Manufacturer) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Manufacturer diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 14bbe3589..27ddb6f31 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -4,7 +4,7 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, UtilizationColumn, ) @@ -27,7 +27,7 @@ class RackRoleTable(BaseTable): tags = TagColumn( url_name='dcim:rackrole_list' ) - actions = ButtonsColumn(RackRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackRole @@ -121,7 +121,7 @@ class RackReservationTable(BaseTable): tags = TagColumn( url_name='dcim:rackreservation_list' ) - actions = ButtonsColumn(RackReservation) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackReservation diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index ceca41c86..5b39e31eb 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -3,7 +3,8 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, + TagColumn, ToggleColumn, ) from .template_code import LOCATION_ELEVATIONS @@ -32,7 +33,7 @@ class RegionTable(BaseTable): tags = TagColumn( url_name='dcim:region_list' ) - actions = ButtonsColumn(Region) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Region @@ -57,7 +58,7 @@ class SiteGroupTable(BaseTable): tags = TagColumn( url_name='dcim:sitegroup_list' ) - actions = ButtonsColumn(SiteGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = SiteGroup diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 62317e636..defef465f 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django.conf import settings from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, MarkdownColumn, ToggleColumn, ) from .models import * @@ -152,7 +152,7 @@ class TagTable(BaseTable): linkify=True ) color = ColorColumn() - actions = ButtonsColumn(Tag) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Tag @@ -233,9 +233,7 @@ class ObjectJournalTable(BaseTable): comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - actions = ButtonsColumn( - model=JournalEntry - ) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = JournalEntry diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index 94bc50b93..ce472cb1a 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ButtonsColumn, MarkdownColumn, TagColumn, ToggleColumn +from utilities.tables import ActionsColumn, BaseTable, MarkdownColumn, TagColumn, ToggleColumn from ipam.models import * __all__ = ( @@ -58,9 +58,8 @@ class FHRPGroupAssignmentTable(BaseTable): group = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=FHRPGroupAssignment, - buttons=('edit', 'delete', 'foo') + actions = ActionsColumn( + actions=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 3fddbf48e..cf81fe722 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -4,8 +4,8 @@ from django_tables2.utils import Accessor from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, - ToggleColumn, UtilizationColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, + UtilizationColumn, ) from ipam.models import * @@ -89,7 +89,7 @@ class RIRTable(BaseTable): tags = TagColumn( url_name='ipam:rir_list' ) - actions = ButtonsColumn(RIR) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = RIR @@ -111,7 +111,7 @@ class ASNTable(BaseTable): url_params={'asn_id': 'pk'}, verbose_name='Sites' ) - actions = ButtonsColumn(ASN) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ASN @@ -173,7 +173,7 @@ class RoleTable(BaseTable): tags = TagColumn( url_name='ipam:role_list' ) - actions = ButtonsColumn(Role) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = Role @@ -405,9 +405,7 @@ class AssignedIPAddressesTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - actions = ButtonsColumn( - model=IPAddress - ) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index ca8d22552..f1a67c698 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -5,8 +5,8 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, - TemplateColumn, ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, + TagColumn, TemplateColumn, ToggleColumn, ) from virtualization.models import VMInterface from ipam.models import * @@ -153,7 +153,9 @@ class VLANDevicesTable(VLANMembersTable): device = tables.Column( linkify=True ) - actions = ButtonsColumn(Interface, buttons=['edit']) + actions = ActionsColumn( + actions=('edit',) + ) class Meta(BaseTable.Meta): model = Interface @@ -165,7 +167,9 @@ class VLANVirtualMachinesTable(VLANMembersTable): virtual_machine = tables.Column( linkify=True ) - actions = ButtonsColumn(VMInterface, buttons=['edit']) + actions = ActionsColumn( + actions=('edit',) + ) class Meta(BaseTable.Meta): model = VMInterface diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 0ae1139bf..b74b52528 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from utilities.tables import ( - BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, + ActionsColumn, BaseTable, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) from .models import * @@ -59,7 +59,7 @@ class TenantGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:tenantgroup_list' ) - actions = ButtonsColumn(TenantGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = TenantGroup @@ -103,7 +103,7 @@ class ContactGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:contactgroup_list' ) - actions = ButtonsColumn(ContactGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactGroup @@ -116,7 +116,7 @@ class ContactRoleTable(BaseTable): name = tables.Column( linkify=True ) - actions = ButtonsColumn(ContactRole) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactRole @@ -164,9 +164,8 @@ class ContactAssignmentTable(BaseTable): role = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=ContactAssignment, - buttons=('edit', 'delete') + actions = ActionsColumn( + actions=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 9000af110..15cbb77c8 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,3 +1,5 @@ +from collections import namedtuple + import django_tables2 as tables from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -205,6 +207,52 @@ class TemplateColumn(tables.TemplateColumn): return ret +ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon']) + + +class ActionsColumn(tables.Column): + attrs = {'td': {'class': 'text-end noprint'}} + empty_values = () + _actions = { + 'edit': ActionsMenuItem('Edit', 'pencil'), + 'delete': ActionsMenuItem('Delete', 'trash-can-outline'), + 'changelog': ActionsMenuItem('Changelog', 'history'), + } + + def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): + super().__init__(*args, **kwargs) + + # Determine which actions to enable + self.actions = { + name: self._actions[name] for name in actions + } + + def header(self): + return '' + + def render(self, record, table, **kwargs): + if not self.actions: + return '' + + model = table.Meta.model + viewname_base = f'{model._meta.app_label}:{model._meta.model_name}' + request = getattr(table, 'context', {}).get('request') + url_appendix = f'?return_url={request.path}' if request else '' + + menu = '' + + return mark_safe(menu) + + class ButtonsColumn(tables.TemplateColumn): """ Render edit, delete, and changelog buttons for an object. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 818b09d33..f04d2825e 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -2,8 +2,8 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, + ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, + TagColumn, ToggleColumn, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -40,7 +40,7 @@ class ClusterTypeTable(BaseTable): tags = TagColumn( url_name='virtualization:clustertype_list' ) - actions = ButtonsColumn(ClusterType) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterType @@ -63,7 +63,7 @@ class ClusterGroupTable(BaseTable): tags = TagColumn( url_name='virtualization:clustergroup_list' ) - actions = ButtonsColumn(ClusterGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterGroup diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 4f47ee7f9..b16b31db8 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from dcim.models import Interface from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, ) from .models import * @@ -26,7 +26,7 @@ class WirelessLANGroupTable(BaseTable): tags = TagColumn( url_name='wireless:wirelesslangroup_list' ) - actions = ButtonsColumn(WirelessLANGroup) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = WirelessLANGroup From 00a8fd654eb4eab47972c3ad7ca2b5bc6752d3e4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 09:12:48 -0500 Subject: [PATCH 072/271] Refactor table utilities --- netbox/utilities/tables/__init__.py | 30 +++ .../{tables.py => tables/columns.py} | 188 ++---------------- netbox/utilities/tables/tables.py | 138 +++++++++++++ 3 files changed, 188 insertions(+), 168 deletions(-) create mode 100644 netbox/utilities/tables/__init__.py rename netbox/utilities/{tables.py => tables/columns.py} (68%) create mode 100644 netbox/utilities/tables/tables.py diff --git a/netbox/utilities/tables/__init__.py b/netbox/utilities/tables/__init__.py new file mode 100644 index 000000000..37dd75144 --- /dev/null +++ b/netbox/utilities/tables/__init__.py @@ -0,0 +1,30 @@ +from django_tables2 import RequestConfig + +from utilities.paginator import EnhancedPaginator, get_paginate_count +from .columns import * +from .tables import * + + +# +# Pagination +# + +def paginate_table(table, request): + """ + Paginate a table given a request context. + """ + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(table) + + +# +# Callables +# + +def linkify_phone(value): + if value is None: + return None + return f"tel:{value}" diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables/columns.py similarity index 68% rename from netbox/utilities/tables.py rename to netbox/utilities/tables/columns.py index 15cbb77c8..177f3bd5b 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables/columns.py @@ -2,150 +2,33 @@ from collections import namedtuple import django_tables2 as tables from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist -from django.db.models.fields.related import RelatedField from django.urls import reverse from django.utils.safestring import mark_safe -from django_tables2 import RequestConfig -from django_tables2.data import TableQuerysetData from django_tables2.utils import Accessor from extras.choices import CustomFieldTypeChoices -from extras.models import CustomField, CustomLink -from .utils import content_type_identifier, content_type_name -from .paginator import EnhancedPaginator, get_paginate_count +from utilities.utils import content_type_identifier, content_type_name +__all__ = ( + 'ActionsColumn', + 'BooleanColumn', + 'ButtonsColumn', + 'ChoiceFieldColumn', + 'ColorColumn', + 'ColoredLabelColumn', + 'ContentTypeColumn', + 'ContentTypesColumn', + 'CustomFieldColumn', + 'CustomLinkColumn', + 'LinkedCountColumn', + 'MarkdownColumn', + 'MPTTColumn', + 'TagColumn', + 'TemplateColumn', + 'ToggleColumn', + 'UtilizationColumn', +) -class BaseTable(tables.Table): - """ - Default table for object lists - - :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. - """ - id = tables.Column( - linkify=True, - verbose_name='ID' - ) - - class Meta: - attrs = { - 'class': 'table table-hover object-list', - } - - def __init__(self, *args, user=None, extra_columns=None, **kwargs): - if extra_columns is None: - extra_columns = [] - - # Add custom field columns - obj_type = ContentType.objects.get_for_model(self._meta.model) - cf_columns = [ - (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) - ] - cl_columns = [ - (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) - ] - extra_columns.extend([*cf_columns, *cl_columns]) - - super().__init__(*args, extra_columns=extra_columns, **kwargs) - - # Set default empty_text if none was provided - if self.empty_text is None: - self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" - - # Hide non-default columns - default_columns = getattr(self.Meta, 'default_columns', list()) - if default_columns: - for column in self.columns: - if column.name not in default_columns: - self.columns.hide(column.name) - - # Apply custom column ordering for user - if user is not None and not isinstance(user, AnonymousUser): - selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") - if selected_columns: - - # Show only persistent or selected columns - for name, column in self.columns.items(): - if name in ['pk', 'actions', *selected_columns]: - self.columns.show(name) - else: - self.columns.hide(name) - - # Rearrange the sequence to list selected columns first, followed by all remaining columns - # TODO: There's probably a more clever way to accomplish this - self.sequence = [ - *[c for c in selected_columns if c in self.columns.names()], - *[c for c in self.columns.names() if c not in selected_columns] - ] - - # PK column should always come first - if 'pk' in self.sequence: - self.sequence.remove('pk') - self.sequence.insert(0, 'pk') - - # Actions column should always come last - if 'actions' in self.sequence: - self.sequence.remove('actions') - self.sequence.append('actions') - - # Dynamically update the table's QuerySet to ensure related fields are pre-fetched - if isinstance(self.data, TableQuerysetData): - - prefetch_fields = [] - for column in self.columns: - if column.visible: - model = getattr(self.Meta, 'model') - accessor = column.accessor - prefetch_path = [] - for field_name in accessor.split(accessor.SEPARATOR): - try: - field = model._meta.get_field(field_name) - except FieldDoesNotExist: - break - if isinstance(field, RelatedField): - # Follow ForeignKeys to the related model - prefetch_path.append(field_name) - model = field.remote_field.model - elif isinstance(field, GenericForeignKey): - # Can't prefetch beyond a GenericForeignKey - prefetch_path.append(field_name) - break - if prefetch_path: - prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) - - def _get_columns(self, visible=True): - columns = [] - for name, column in self.columns.items(): - if column.visible == visible and name not in ['pk', 'actions']: - columns.append((name, column.verbose_name)) - return columns - - @property - def available_columns(self): - return self._get_columns(visible=False) - - @property - def selected_columns(self): - return self._get_columns(visible=True) - - @property - def objects_count(self): - """ - Return the total number of real objects represented by the Table. This is useful when dealing with - prefixes/IP addresses/etc., where some table rows may represent available address space. - """ - if not hasattr(self, '_objects_count'): - self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) - return self._objects_count - - -# -# Table columns -# class ToggleColumn(tables.CheckBoxColumn): """ @@ -557,34 +440,3 @@ class MarkdownColumn(tables.TemplateColumn): def value(self, value): return value - - -# -# Pagination -# - -def paginate_table(table, request): - """ - Paginate a table given a request context. - """ - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(table) - - -# -# Callables -# - -def linkify_email(value): - if value is None: - return None - return f"mailto:{value}" - - -def linkify_phone(value): - if value is None: - return None - return f"tel:{value}" diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py new file mode 100644 index 000000000..a18800595 --- /dev/null +++ b/netbox/utilities/tables/tables.py @@ -0,0 +1,138 @@ +import django_tables2 as tables +from django.contrib.auth.models import AnonymousUser +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldDoesNotExist +from django.db.models.fields.related import RelatedField +from django_tables2.data import TableQuerysetData + +from extras.models import CustomField, CustomLink +from . import columns + +__all__ = ( + 'BaseTable', +) + + +class BaseTable(tables.Table): + """ + Default table for object lists + + :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. + """ + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + + class Meta: + attrs = { + 'class': 'table table-hover object-list', + } + + def __init__(self, *args, user=None, extra_columns=None, **kwargs): + if extra_columns is None: + extra_columns = [] + + # Add custom field columns + obj_type = ContentType.objects.get_for_model(self._meta.model) + cf_columns = [ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) + ] + cl_columns = [ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) + ] + extra_columns.extend([*cf_columns, *cl_columns]) + + super().__init__(*args, extra_columns=extra_columns, **kwargs) + + # Set default empty_text if none was provided + if self.empty_text is None: + self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" + + # Hide non-default columns + default_columns = getattr(self.Meta, 'default_columns', list()) + if default_columns: + for column in self.columns: + if column.name not in default_columns: + self.columns.hide(column.name) + + # Apply custom column ordering for user + if user is not None and not isinstance(user, AnonymousUser): + selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") + if selected_columns: + + # Show only persistent or selected columns + for name, column in self.columns.items(): + if name in ['pk', 'actions', *selected_columns]: + self.columns.show(name) + else: + self.columns.hide(name) + + # Rearrange the sequence to list selected columns first, followed by all remaining columns + # TODO: There's probably a more clever way to accomplish this + self.sequence = [ + *[c for c in selected_columns if c in self.columns.names()], + *[c for c in self.columns.names() if c not in selected_columns] + ] + + # PK column should always come first + if 'pk' in self.sequence: + self.sequence.remove('pk') + self.sequence.insert(0, 'pk') + + # Actions column should always come last + if 'actions' in self.sequence: + self.sequence.remove('actions') + self.sequence.append('actions') + + # Dynamically update the table's QuerySet to ensure related fields are pre-fetched + if isinstance(self.data, TableQuerysetData): + + prefetch_fields = [] + for column in self.columns: + if column.visible: + model = getattr(self.Meta, 'model') + accessor = column.accessor + prefetch_path = [] + for field_name in accessor.split(accessor.SEPARATOR): + try: + field = model._meta.get_field(field_name) + except FieldDoesNotExist: + break + if isinstance(field, RelatedField): + # Follow ForeignKeys to the related model + prefetch_path.append(field_name) + model = field.remote_field.model + elif isinstance(field, GenericForeignKey): + # Can't prefetch beyond a GenericForeignKey + prefetch_path.append(field_name) + break + if prefetch_path: + prefetch_fields.append('__'.join(prefetch_path)) + self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + + def _get_columns(self, visible=True): + columns = [] + for name, column in self.columns.items(): + if column.visible == visible and name not in ['pk', 'actions']: + columns.append((name, column.verbose_name)) + return columns + + @property + def available_columns(self): + return self._get_columns(visible=False) + + @property + def selected_columns(self): + return self._get_columns(visible=True) + + @property + def objects_count(self): + """ + Return the total number of real objects represented by the Table. This is useful when dealing with + prefixes/IP addresses/etc., where some table rows may represent available address space. + """ + if not hasattr(self, '_objects_count'): + self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) + return self._objects_count From 6d48ce4a253f687b1b26d147da8f482a50054d5b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 10:36:58 -0500 Subject: [PATCH 073/271] Always include actions as a default column --- netbox/circuits/tables.py | 5 ++--- netbox/dcim/tables/devices.py | 29 +++++++++++------------------ netbox/dcim/tables/devicetypes.py | 3 +-- netbox/dcim/tables/racks.py | 12 ++++-------- netbox/dcim/tables/sites.py | 12 +++++------- netbox/extras/tables.py | 8 +++----- netbox/ipam/tables/ip.py | 15 +++++---------- netbox/ipam/tables/vlans.py | 2 +- netbox/tenancy/tables.py | 11 ++++------- netbox/utilities/tables/columns.py | 3 ++- netbox/utilities/tables/tables.py | 12 ++++++------ netbox/virtualization/tables.py | 15 ++++++--------- netbox/wireless/tables.py | 7 ++----- 13 files changed, 52 insertions(+), 82 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 59ef073d3..c99bec8bc 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import TenantColumn -from utilities.tables import ActionsColumn, BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from .models import * @@ -88,12 +88,11 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = CircuitType fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') # diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 21da569a7..f21bc3204 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -94,7 +94,6 @@ class DeviceRoleTable(BaseTable): tags = TagColumn( url_name='dcim:devicerole_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = DeviceRole @@ -102,7 +101,7 @@ class DeviceRoleTable(BaseTable): 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') + default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') # @@ -127,7 +126,6 @@ class PlatformTable(BaseTable): tags = TagColumn( url_name='dcim:platform_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Platform @@ -136,7 +134,7 @@ class PlatformTable(BaseTable): 'description', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', ) @@ -336,7 +334,7 @@ class DeviceConsolePortTable(ConsolePortTable): 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } @@ -381,7 +379,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } @@ -428,7 +426,6 @@ class DevicePowerPortTable(PowerPortTable): ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class @@ -477,7 +474,7 @@ class DevicePowerOutletTable(PowerOutletTable): 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', + 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', ) row_attrs = { 'class': get_cabletermination_row_class @@ -572,7 +569,7 @@ class DeviceInterfaceTable(InterfaceTable): order_by = ('name',) default_columns = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', - 'cable', 'connection', 'actions', + 'cable', 'connection', ) row_attrs = { 'class': get_interface_row_class, @@ -631,7 +628,6 @@ class DeviceFrontPortTable(FrontPortTable): ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class @@ -679,7 +675,7 @@ class DeviceRearPortTable(RearPortTable): 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', ) row_attrs = { 'class': get_cabletermination_row_class @@ -728,9 +724,7 @@ class DeviceDeviceBayTable(DeviceBayTable): fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) - default_columns = ( - 'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions', - ) + default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description') class ModuleBayTable(DeviceComponentTable): @@ -764,7 +758,7 @@ class DeviceModuleBayTable(ModuleBayTable): class Meta(DeviceComponentTable.Meta): model = ModuleBay fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions') - default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'installed_module') class InventoryItemTable(DeviceComponentTable): @@ -821,7 +815,7 @@ class DeviceInventoryItemTable(InventoryItemTable): 'description', 'discovered', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions', + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', ) @@ -839,14 +833,13 @@ class InventoryItemRoleTable(BaseTable): tags = TagColumn( url_name='dcim:inventoryitemrole_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description') # diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index df27a366f..29fa4d4de 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -49,7 +49,6 @@ class ManufacturerTable(BaseTable): tags = TagColumn( url_name='dcim:manufacturer_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Manufacturer @@ -58,7 +57,7 @@ class ManufacturerTable(BaseTable): 'actions', ) default_columns = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 27ddb6f31..565966a39 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -4,8 +4,8 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, - TagColumn, ToggleColumn, UtilizationColumn, + BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ToggleColumn, UtilizationColumn, ) __all__ = ( @@ -27,12 +27,11 @@ class RackRoleTable(BaseTable): tags = TagColumn( url_name='dcim:rackrole_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackRole fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'rack_count', 'color', 'description') # @@ -121,7 +120,6 @@ class RackReservationTable(BaseTable): tags = TagColumn( url_name='dcim:rackreservation_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = RackReservation @@ -129,6 +127,4 @@ class RackReservationTable(BaseTable): 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', 'actions', ) - default_columns = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', - ) + default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 5b39e31eb..23ffabae2 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -3,8 +3,7 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, - TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) from .template_code import LOCATION_ELEVATIONS @@ -33,12 +32,11 @@ class RegionTable(BaseTable): tags = TagColumn( url_name='dcim:region_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Region fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description') # @@ -58,12 +56,11 @@ class SiteGroupTable(BaseTable): tags = TagColumn( url_name='dcim:sitegroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = SiteGroup fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description') # @@ -99,6 +96,7 @@ class SiteTable(BaseTable): fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', + 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') @@ -140,4 +138,4 @@ class LocationTable(BaseTable): 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index defef465f..e30bc6907 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -152,12 +152,11 @@ class TagTable(BaseTable): linkify=True ) color = ColorColumn() - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Tag fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions') - default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') class TaggedItemTable(BaseTable): @@ -215,6 +214,7 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) + actions = ActionsColumn(actions=()) class Meta(BaseTable.Meta): model = ObjectChange @@ -233,7 +233,6 @@ class ObjectJournalTable(BaseTable): comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = JournalEntry @@ -259,6 +258,5 @@ class JournalEntryTable(ObjectJournalTable): 'comments', 'actions' ) default_columns = ( - 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', - 'comments', 'actions' + 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments' ) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index cf81fe722..9914fb22b 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -2,12 +2,11 @@ import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor +from ipam.models import * from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, - UtilizationColumn, + BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, UtilizationColumn, ) -from ipam.models import * __all__ = ( 'AggregateTable', @@ -89,12 +88,11 @@ class RIRTable(BaseTable): tags = TagColumn( url_name='ipam:rir_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = RIR fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') + default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description') # @@ -111,12 +109,11 @@ class ASNTable(BaseTable): url_params={'asn_id': 'pk'}, verbose_name='Sites' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ASN fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') - default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions') + default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant') # @@ -173,12 +170,11 @@ class RoleTable(BaseTable): tags = TagColumn( url_name='ipam:role_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = Role fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') - default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') + default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description') # @@ -405,7 +401,6 @@ class AssignedIPAddressesTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - actions = ActionsColumn() class Meta(BaseTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index f1a67c698..1470b3d1a 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -88,7 +88,7 @@ class VLANGroupTable(BaseTable): 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') # diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index b74b52528..72878ef29 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -59,12 +59,11 @@ class TenantGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:tenantgroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = TenantGroup fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') + default_columns = ('pk', 'name', 'tenant_count', 'description') class TenantTable(BaseTable): @@ -103,12 +102,11 @@ class ContactGroupTable(BaseTable): tags = TagColumn( url_name='tenancy:contactgroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactGroup fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') + default_columns = ('pk', 'name', 'contact_count', 'description') class ContactRoleTable(BaseTable): @@ -116,12 +114,11 @@ class ContactRoleTable(BaseTable): name = tables.Column( linkify=True ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ContactRole fields = ('pk', 'name', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'description', 'actions') + default_columns = ('pk', 'name', 'description') class ContactTable(BaseTable): @@ -171,4 +168,4 @@ class ContactAssignmentTable(BaseTable): class Meta(BaseTable.Meta): model = ContactAssignment fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') - default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') + default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index 177f3bd5b..468d81ab7 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -114,7 +114,8 @@ class ActionsColumn(tables.Column): return '' def render(self, record, table, **kwargs): - if not self.actions: + # Skip dummy records (e.g. available VLANs) or those with no actions + if not hasattr(record, 'pk') or not self.actions: return '' model = table.Meta.model diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py index a18800595..6c3b56959 100644 --- a/netbox/utilities/tables/tables.py +++ b/netbox/utilities/tables/tables.py @@ -24,6 +24,7 @@ class BaseTable(tables.Table): linkify=True, verbose_name='ID' ) + actions = columns.ActionsColumn() class Meta: attrs = { @@ -50,12 +51,11 @@ class BaseTable(tables.Table): if self.empty_text is None: self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" - # Hide non-default columns - default_columns = getattr(self.Meta, 'default_columns', list()) - if default_columns: - for column in self.columns: - if column.name not in default_columns: - self.columns.hide(column.name) + # Hide non-default columns (except for actions) + default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), 'actions'] + for column in self.columns: + if column.name not in default_columns: + self.columns.hide(column.name) # Apply custom column ordering for user if user is not None and not isinstance(user, AnonymousUser): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index f04d2825e..65f9f1257 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,9 +1,10 @@ import django_tables2 as tables + from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, - TagColumn, ToggleColumn, + BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ToggleColumn, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -40,12 +41,11 @@ class ClusterTypeTable(BaseTable): tags = TagColumn( url_name='virtualization:clustertype_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterType fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') + default_columns = ('pk', 'name', 'cluster_count', 'description') # @@ -63,12 +63,11 @@ class ClusterGroupTable(BaseTable): tags = TagColumn( url_name='virtualization:clustergroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = ClusterGroup fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') + default_columns = ('pk', 'name', 'cluster_count', 'description') # @@ -196,9 +195,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) - default_columns = ( - 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', - ) + default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') row_attrs = { 'data-name': lambda record: record.name, } diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index b16b31db8..67d46f248 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,9 +1,7 @@ import django_tables2 as tables from dcim.models import Interface -from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, -) +from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn from .models import * __all__ = ( @@ -26,12 +24,11 @@ class WirelessLANGroupTable(BaseTable): tags = TagColumn( url_name='wireless:wirelesslangroup_list' ) - actions = ActionsColumn() class Meta(BaseTable.Meta): model = WirelessLANGroup fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions') + default_columns = ('pk', 'name', 'wirelesslan_count', 'description') class WirelessLANTable(BaseTable): From ededa69e4ab12379e360fc1ce8ab204d6379ac42 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 10:53:00 -0500 Subject: [PATCH 074/271] Only show relevant links for user permissions --- netbox/utilities/tables/columns.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index 468d81ab7..90505f2da 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -90,16 +90,16 @@ class TemplateColumn(tables.TemplateColumn): return ret -ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon']) +ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon', 'permission']) class ActionsColumn(tables.Column): attrs = {'td': {'class': 'text-end noprint'}} empty_values = () _actions = { - 'edit': ActionsMenuItem('Edit', 'pencil'), - 'delete': ActionsMenuItem('Delete', 'trash-can-outline'), - 'changelog': ActionsMenuItem('Changelog', 'history'), + 'edit': ActionsMenuItem('Edit', 'pencil', 'change'), + 'delete': ActionsMenuItem('Delete', 'trash-can-outline', 'delete'), + 'changelog': ActionsMenuItem('Changelog', 'history', None), } def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): @@ -123,16 +123,21 @@ class ActionsColumn(tables.Column): request = getattr(table, 'context', {}).get('request') url_appendix = f'?return_url={request.path}' if request else '' - menu = '' + if not links: + return '' + + menu = f'' return mark_safe(menu) From 1024adca72570f58ac899850c5ca66bf782ee528 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 11:00:35 -0500 Subject: [PATCH 075/271] Exclude actions column from export --- netbox/netbox/views/generic/object_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 607501a9b..d8850391b 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -203,7 +203,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): :param table: The Table instance to export :param columns: A list of specific columns to include. If not specified, all columns will be exported. """ - exclude_columns = {'pk'} + exclude_columns = {'pk', 'actions'} if columns: all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] exclude_columns.update({ From bff7400de4503b8f25938680572e28c634f09ed4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 11:23:04 -0500 Subject: [PATCH 076/271] Convert ActionsMenuItem to dataclass --- netbox/utilities/tables/columns.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index 90505f2da..df9db357f 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -1,4 +1,6 @@ from collections import namedtuple +from dataclasses import dataclass +from typing import Optional import django_tables2 as tables from django.conf import settings @@ -90,16 +92,20 @@ class TemplateColumn(tables.TemplateColumn): return ret -ActionsMenuItem = namedtuple('ActionsMenuItem', ['title', 'icon', 'permission']) +@dataclass +class ActionsItem: + title: str + icon: str + permission: Optional[str] = None class ActionsColumn(tables.Column): attrs = {'td': {'class': 'text-end noprint'}} empty_values = () _actions = { - 'edit': ActionsMenuItem('Edit', 'pencil', 'change'), - 'delete': ActionsMenuItem('Delete', 'trash-can-outline', 'delete'), - 'changelog': ActionsMenuItem('Changelog', 'history', None), + 'edit': ActionsItem('Edit', 'pencil', 'change'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'changelog': ActionsItem('Changelog', 'history'), } def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): From 8b07fbc5544a7435f4178811f4927a7f606b6aaf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 11:56:18 -0500 Subject: [PATCH 077/271] Allow passing additional columns & specifying a sequence --- netbox/extras/tables.py | 2 +- netbox/ipam/tables/fhrp.py | 2 +- netbox/ipam/tables/vlans.py | 4 ++-- netbox/tenancy/tables.py | 2 +- netbox/utilities/tables/columns.py | 10 +++++++--- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index e30bc6907..071caa354 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -214,7 +214,7 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) - actions = ActionsColumn(actions=()) + actions = ActionsColumn(sequence=()) class Meta(BaseTable.Meta): model = ObjectChange diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index ce472cb1a..a691b945b 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -59,7 +59,7 @@ class FHRPGroupAssignmentTable(BaseTable): linkify=True ) actions = ActionsColumn( - actions=('edit', 'delete') + sequence=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 1470b3d1a..1379ad105 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -154,7 +154,7 @@ class VLANDevicesTable(VLANMembersTable): linkify=True ) actions = ActionsColumn( - actions=('edit',) + sequence=('edit',) ) class Meta(BaseTable.Meta): @@ -168,7 +168,7 @@ class VLANVirtualMachinesTable(VLANMembersTable): linkify=True ) actions = ActionsColumn( - actions=('edit',) + sequence=('edit',) ) class Meta(BaseTable.Meta): diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 72878ef29..f15e67eab 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -162,7 +162,7 @@ class ContactAssignmentTable(BaseTable): linkify=True ) actions = ActionsColumn( - actions=('edit', 'delete') + sequence=('edit', 'delete') ) class Meta(BaseTable.Meta): diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index df9db357f..e601bd0cc 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -102,18 +102,22 @@ class ActionsItem: class ActionsColumn(tables.Column): attrs = {'td': {'class': 'text-end noprint'}} empty_values = () - _actions = { + actions = { 'edit': ActionsItem('Edit', 'pencil', 'change'), 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, actions=('edit', 'delete', 'changelog'), **kwargs): + def __init__(self, *args, extra_actions=None, sequence=('edit', 'delete', 'changelog'), **kwargs): super().__init__(*args, **kwargs) + # Add/update any extra actions passed + if extra_actions: + self.actions.update(extra_actions) + # Determine which actions to enable self.actions = { - name: self._actions[name] for name in actions + name: self.actions[name] for name in sequence } def header(self): From 3e277de82d5c8e08878f416a3a888f0497942c3a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 14:57:43 -0500 Subject: [PATCH 078/271] Closes #7852: Enable assigning interfaces to VRFs --- docs/models/dcim/interface.md | 2 +- docs/release-notes/version-3.2.md | 3 ++- netbox/dcim/api/serializers.py | 7 +++++-- netbox/dcim/api/views.py | 2 +- netbox/dcim/filtersets.py | 13 +++++++++++- netbox/dcim/forms/bulk_edit.py | 12 ++++++++--- netbox/dcim/forms/bulk_import.py | 9 +++++++- netbox/dcim/forms/filtersets.py | 10 +++++++-- netbox/dcim/forms/models.py | 11 +++++++--- netbox/dcim/migrations/0149_interface_vrf.py | 20 ++++++++++++++++++ netbox/dcim/models/device_components.py | 8 +++++++ netbox/dcim/tables/devices.py | 5 ++++- netbox/dcim/tests/test_api.py | 12 ++++++++++- netbox/dcim/tests/test_filtersets.py | 22 ++++++++++++++++---- netbox/dcim/tests/test_views.py | 20 +++++++++++++----- netbox/templates/dcim/interface.html | 10 +++++++++ netbox/templates/dcim/interface_edit.html | 1 + 17 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 netbox/dcim/migrations/0149_interface_vrf.py diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 585674de1..7fa52fa9f 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -1,6 +1,6 @@ ## Interfaces -Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). +Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Additionally, each interface may optionally be assigned to a VRF. !!! note Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6240016cf..5ec7b5a82 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -61,6 +61,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components +* [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group ### Other Changes @@ -88,7 +89,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * dcim.FrontPort * Added `module` field * dcim.Interface - * Added `module` field + * Added `module` and `vrf` fields * dcim.InventoryItem * Added `component_type`, `component_id`, and `role` fields * Added read-only `component` field diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3bc369a64..4d8638231 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,7 +6,9 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer +from ipam.api.nested_serializers import ( + NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, +) from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( @@ -728,6 +730,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con required=False, many=True ) + vrf = NestedVRFSerializer(required=False, allow_null=True) cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( @@ -745,7 +748,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', - 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', + 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 31c1fd1d0..edba03b60 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -583,7 +583,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', - 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 9069ab25c..104836120 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet -from ipam.models import ASN +from ipam.models import ASN, VRF from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, ) @@ -1217,6 +1217,17 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT rf_channel = django_filters.MultipleChoiceFilter( choices=WirelessChannelChoices ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='vrf', + queryset=VRF.objects.all(), + label='VRF', + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) class Meta: model = Interface diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 3cd8ec35e..69fa6eb3a 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -7,7 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.models import VLAN, ASN +from ipam.models import ASN, VLAN, VRF from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, @@ -1061,7 +1061,8 @@ class InterfaceBulkEditForm( required=False, query_params={ 'type': 'lag', - } + }, + label='LAG' ) mgmt_only = forms.NullBooleanField( required=False, @@ -1080,11 +1081,16 @@ class InterfaceBulkEditForm( queryset=VLAN.objects.all(), required=False ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) class Meta: nullable_fields = [ 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 1297fc980..fce98f7cb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -8,6 +8,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelCSVForm +from ipam.models import VRF from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster @@ -622,6 +623,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): required=False, help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Assigned VRF' + ) rf_role = CSVChoiceField( choices=WirelessRoleChoices, required=False, @@ -632,7 +639,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', - 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index eb3035122..c231f56df 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -6,7 +6,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm -from ipam.models import ASN +from ipam.models import ASN, VRF from tenancy.forms import TenancyFilterForm from utilities.forms import ( APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, @@ -920,7 +920,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface field_groups = [ ['q', 'tag'], - ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], + ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only'], + ['vrf_id', 'mac_address', 'wwn'], ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] @@ -980,6 +981,11 @@ class InterfaceFilterForm(DeviceComponentFilterForm): min_value=0, max_value=127 ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 65b7d46a8..801659574 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -9,7 +9,7 @@ from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelForm from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup, ASN +from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, @@ -1261,6 +1261,11 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'available_on_device': '$device', } ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1271,11 +1276,11 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', + 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] fieldsets = ( ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), - ('Addressing', ('mac_address', 'wwn')), + ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), diff --git a/netbox/dcim/migrations/0149_interface_vrf.py b/netbox/dcim/migrations/0149_interface_vrf.py new file mode 100644 index 000000000..224671f5b --- /dev/null +++ b/netbox/dcim/migrations/0149_interface_vrf.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.11 on 2022-01-07 18:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0054_vlangroup_min_max_vids'), + ('dcim', '0148_inventoryitem_templates'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces', to='ipam.vrf'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index cdfaa7c89..916161ced 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -616,6 +616,14 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo blank=True, verbose_name='Tagged VLANs' ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.SET_NULL, + related_name='interfaces', + null=True, + blank=True, + verbose_name='VRF' + ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0c3a5f6a1..b8963eae7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -521,6 +521,9 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi orderable=False, verbose_name='Wireless LANs' ) + vrf = tables.Column( + linkify=True + ) tags = TagColumn( url_name='dcim:interface_list' ) @@ -531,7 +534,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', - 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 0c9b918df..1c6f53693 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, RIR, VLAN +from ipam.models import ASN, RIR, VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.models import WirelessLAN @@ -1424,6 +1424,13 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ) WirelessLAN.objects.bulk_create(wireless_lans) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) + cls.create_data = [ { 'device': device.pk, @@ -1431,6 +1438,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'type': '1000base-t', 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, + 'vrf': vrfs[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], @@ -1442,6 +1450,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'mode': InterfaceModeChoices.MODE_TAGGED, 'bridge': interfaces[0].pk, 'tx_power': 10, + 'vrf': vrfs[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], @@ -1453,6 +1462,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'mode': InterfaceModeChoices.MODE_TAGGED, 'parent': interfaces[1].pk, 'tx_power': 10, + 'vrf': vrfs[2].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2973e46e7..7b2e35009 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import ASN, IPAddress, RIR +from ipam.models import ASN, IPAddress, RIR, VRF from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests, create_test_device @@ -2370,15 +2370,22 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + VRF(name='VRF 3', rd='65000:3'), + ) + VRF.objects.bulk_create(vrfs) + # VirtualChassis assignment for filtering virtual_chassis = VirtualChassis.objects.create(master=devices[0]) Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), - Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), - Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'), + Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0]), + Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1]), + Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2]), Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), @@ -2550,6 +2557,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tx_power': [40]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8f7cb606b..1b39285d4 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, RIR, VLAN +from ipam.models import ASN, RIR, VLAN, VRF from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device from wireless.models import WirelessLAN @@ -2105,6 +2105,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) WirelessLAN.objects.bulk_create(wireless_lans) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -2124,6 +2131,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } @@ -2143,6 +2151,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } @@ -2159,13 +2168,14 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'tx_power': 10, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'vrf': vrfs[1].pk, } cls.csv_data = ( - "device,name,type", - "Device 1,Interface 4,1000base-t", - "Device 1,Interface 5,1000base-t", - "Device 1,Interface 6,1000base-t", + f"device,name,type,vrf.pk", + f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}", + f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}", + f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}", ) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 8959379a8..bc9611992 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -108,6 +108,16 @@
    802.1Q Mode {{ object.get_mode_display|placeholder }}
    VRF + {% if object.vrf %} + {{ object.vrf }} + {% else %} + None + {% endif %} +
    diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index a5f686633..f41e5ced6 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -25,6 +25,7 @@
    Addressing
    + {% render_field form.vrf %} {% render_field form.mac_address %} {% render_field form.wwn %}
    From 0f58faaddbfce5eef16e9ecc56bdf1528769e21c Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sat, 8 Jan 2022 12:25:30 -0600 Subject: [PATCH 079/271] #7853 - Initial work on Speed/Duplex. TODO: Documentation, Tests, Form order --- netbox/dcim/api/serializers.py | 3 ++- netbox/dcim/choices.py | 13 +++++++++++ netbox/dcim/filtersets.py | 2 ++ netbox/dcim/forms/bulk_create.py | 4 ++-- netbox/dcim/forms/bulk_edit.py | 11 ++++++--- netbox/dcim/forms/bulk_import.py | 6 ++++- netbox/dcim/forms/filtersets.py | 14 +++++++++-- netbox/dcim/forms/models.py | 7 +++--- .../migrations/0150_interface_speed_duplex.py | 23 +++++++++++++++++++ netbox/dcim/models/device_components.py | 12 ++++++++++ netbox/templates/dcim/interface.html | 8 +++++++ netbox/templates/dcim/interface_edit.html | 2 ++ 12 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 netbox/dcim/migrations/0150_interface_speed_duplex.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4d8638231..766373796 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -721,6 +721,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + duplex = ChoiceField(choices=InterfaceDuplexChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -746,7 +747,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', - 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', + 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 368ee1336..fa158c750 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -943,6 +943,19 @@ class InterfaceTypeChoices(ChoiceSet): ) +class InterfaceDuplexChoices(ChoiceSet): + + DUPLEX_HALF = 'half' + DUPLEX_FULL = 'full' + DUPLEX_AUTO = 'auto' + + CHOICES = ( + (DUPLEX_HALF, 'Half'), + (DUPLEX_FULL, 'Full'), + (DUPLEX_AUTO, 'Auto'), + ) + + class InterfaceModeChoices(ChoiceSet): MODE_ACCESS = 'access' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 104836120..8a83a8a6b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1196,6 +1196,8 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT queryset=Interface.objects.all(), label='LAG interface (ID)', ) + speed = MultiValueNumberFilter() + duplex = django_filters.CharFilter() mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() tag = TagFilter() diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 02c8feb4b..4d73fcc2a 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -72,12 +72,12 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 69fa6eb3a..3d73ada47 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,7 +11,7 @@ from ipam.models import ASN, VLAN, VRF from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, + DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, ) __all__ = ( @@ -1028,7 +1028,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', + 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ]), AddRemoveTagsForm, @@ -1064,6 +1064,11 @@ class InterfaceBulkEditForm( }, label='LAG' ) + speed = forms.IntegerField( + required=False, + widget=SelectSpeedWidget(attrs={'readonly': None}), + label='Speed' + ) mgmt_only = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, @@ -1089,7 +1094,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', + 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index fce98f7cb..ef8d79082 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -618,6 +618,10 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): choices=InterfaceTypeChoices, help_text='Physical medium' ) + duplex = CSVChoiceField( + choices=InterfaceDuplexChoices, + help_text='Duplex' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -638,7 +642,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index c231f56df..188a5f242 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -10,7 +10,7 @@ from ipam.models import ASN, VRF from tenancy.forms import TenancyFilterForm from utilities.forms import ( APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, - StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget, ) from wireless.choices import * @@ -920,7 +920,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface field_groups = [ ['q', 'tag'], - ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only'], + ['name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only'], ['vrf_id', 'mac_address', 'wwn'], ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], @@ -935,6 +935,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelectMultiple() ) + speed = forms.IntegerField( + required=False, + label='Select Speed', + widget=SelectSpeedWidget(attrs={'readonly': None}) + ) + duplex = forms.ChoiceField( + choices=InterfaceDuplexChoices, + required=False, + label='Select Duplex' + ) enabled = forms.NullBooleanField( required=False, widget=StaticSelect( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 801659574..07fa07e12 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -14,7 +14,7 @@ from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, - SlugField, StaticSelect, + SlugField, StaticSelect, SelectSpeedWidget, ) from virtualization.models import Cluster, ClusterGroup from wireless.models import WirelessLAN, WirelessLANGroup @@ -1274,12 +1274,12 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', + 'device', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] fieldsets = ( - ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), + ('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1295,6 +1295,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), + 'speed': SelectSpeedWidget(attrs={'readonly': None}), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/dcim/migrations/0150_interface_speed_duplex.py b/netbox/dcim/migrations/0150_interface_speed_duplex.py new file mode 100644 index 000000000..f9517107a --- /dev/null +++ b/netbox/dcim/migrations/0150_interface_speed_duplex.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.10 on 2022-01-08 18:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0149_interface_vrf'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='duplex', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='interface', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 916161ced..d876e7755 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -551,6 +551,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo verbose_name='Management only', help_text='This interface is used only for out-of-band management' ) + speed = models.PositiveIntegerField( + verbose_name='Speed', + blank=True, + null=True + ) + duplex = models.CharField( + verbose_name='Duplex', + max_length=50, + blank=True, + null=True, + choices=InterfaceDuplexChoices + ) wwn = WWNField( null=True, blank=True, diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bc9611992..bf81a33f2 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -46,6 +46,14 @@ Type {{ object.get_type_display }} + + Speed + {{ object.speed|humanize_speed|placeholder }} + + + Duplex + {{ object.get_duplex_display }} + Enabled {% checkmark object.enabled %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index f41e5ced6..e45cdd685 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -16,6 +16,8 @@ {% endif %} {% render_field form.name %} {% render_field form.type %} + {% render_field form.speed %} + {% render_field form.duplex %} {% render_field form.label %} {% render_field form.description %} {% render_field form.tags %} From aed23d61fc752bc6a913759995ff1d57707e1602 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 11:17:40 -0500 Subject: [PATCH 080/271] Replace ButtonsColumn with ActionsColumn --- netbox/dcim/tables/devices.py | 61 ++++++------------ netbox/dcim/tables/devicetypes.py | 67 +++++++++----------- netbox/dcim/tables/sites.py | 9 ++- netbox/dcim/tables/template_code.py | 6 +- netbox/ipam/tables/vlans.py | 11 ++-- netbox/utilities/tables/columns.py | 89 ++++++++------------------- netbox/utilities/tests/test_tables.py | 3 +- netbox/virtualization/tables.py | 9 ++- 8 files changed, 90 insertions(+), 165 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f21bc3204..1241143b7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -7,7 +7,7 @@ from dcim.models import ( ) from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import * @@ -322,10 +322,8 @@ class DeviceConsolePortTable(ConsolePortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsolePort, - buttons=('edit', 'delete'), - prepend_template=CONSOLEPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=CONSOLEPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -367,10 +365,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsoleServerPort, - buttons=('edit', 'delete'), - prepend_template=CONSOLESERVERPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=CONSOLESERVERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -412,10 +408,8 @@ class DevicePowerPortTable(PowerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerPort, - buttons=('edit', 'delete'), - prepend_template=POWERPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=POWERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -461,10 +455,8 @@ class DevicePowerOutletTable(PowerOutletTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerOutlet, - buttons=('edit', 'delete'), - prepend_template=POWEROUTLET_BUTTONS + actions = ActionsColumn( + extra_buttons=POWEROUTLET_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -551,10 +543,8 @@ class DeviceInterfaceTable(InterfaceTable): linkify=True, verbose_name='LAG' ) - actions = ButtonsColumn( - model=Interface, - buttons=('edit', 'delete'), - prepend_template=INTERFACE_BUTTONS + actions = ActionsColumn( + extra_buttons=INTERFACE_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -614,10 +604,8 @@ class DeviceFrontPortTable(FrontPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=FrontPort, - buttons=('edit', 'delete'), - prepend_template=FRONTPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=FRONTPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -662,10 +650,8 @@ class DeviceRearPortTable(RearPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=RearPort, - buttons=('edit', 'delete'), - prepend_template=REARPORT_BUTTONS + actions = ActionsColumn( + extra_buttons=REARPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -713,10 +699,8 @@ class DeviceDeviceBayTable(DeviceBayTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=DEVICEBAY_BUTTONS + actions = ActionsColumn( + extra_buttons=DEVICEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -749,10 +733,8 @@ class ModuleBayTable(DeviceComponentTable): class DeviceModuleBayTable(ModuleBayTable): - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=MODULEBAY_BUTTONS + actions = ActionsColumn( + extra_buttons=MODULEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -803,10 +785,7 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) + actions = ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItem diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 29fa4d4de..ecec67f7d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -6,8 +6,7 @@ from dcim.models import ( InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -113,10 +112,9 @@ class ComponentTemplateTable(BaseTable): class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsolePortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -126,10 +124,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsoleServerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -139,10 +136,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -152,10 +148,9 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerOutletTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -168,10 +163,9 @@ class InterfaceTemplateTable(ComponentTemplateTable): mgmt_only = BooleanColumn( verbose_name='Management Only' ) - actions = ButtonsColumn( - model=InterfaceTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -185,10 +179,9 @@ class FrontPortTemplateTable(ComponentTemplateTable): verbose_name='Position' ) color = ColorColumn() - actions = ButtonsColumn( - model=FrontPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -199,10 +192,9 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): color = ColorColumn() - actions = ButtonsColumn( - model=RearPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -212,9 +204,8 @@ class RearPortTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ModuleBayTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -224,9 +215,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=DeviceBayTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -236,9 +226,8 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class InventoryItemTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=InventoryItemTemplate, - buttons=('edit', 'delete') + actions = ActionsColumn( + sequence=('edit', 'delete') ) role = tables.Column( linkify=True diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 23ffabae2..98c5e3fd3 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -3,9 +3,9 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ) -from .template_code import LOCATION_ELEVATIONS +from .template_code import LOCATION_BUTTONS __all__ = ( 'LocationTable', @@ -127,9 +127,8 @@ class LocationTable(BaseTable): tags = TagColumn( url_name='dcim:location_list' ) - actions = ButtonsColumn( - model=Location, - prepend_template=LOCATION_ELEVATIONS + actions = ActionsColumn( + extra_buttons=LOCATION_BUTTONS ) class Meta(BaseTable.Meta): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2b6c02b82..a1baeb336 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """ {{ value }} """ -LOCATION_ELEVATIONS = """ +LOCATION_BUTTONS = """ @@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ {% load helpers %} -{% if perms.dcim.add_invnetoryitemtemplate %} - +{% if perms.dcim.add_inventoryitemtemplate %} + {% endif %} diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 1379ad105..3454ddff4 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -5,8 +5,8 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import TenantColumn from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, - TagColumn, TemplateColumn, ToggleColumn, + ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, + TemplateColumn, ToggleColumn, ) from virtualization.models import VMInterface from ipam.models import * @@ -38,7 +38,7 @@ VLAN_PREFIXES = """ {% endfor %} """ -VLANGROUP_ADD_VLAN = """ +VLANGROUP_BUTTONS = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -77,9 +77,8 @@ class VLANGroupTable(BaseTable): tags = TagColumn( url_name='ipam:vlangroup_list' ) - actions = ButtonsColumn( - model=VLANGroup, - prepend_template=VLANGROUP_ADD_VLAN + actions = ActionsColumn( + extra_buttons=VLANGROUP_BUTTONS ) class Meta(BaseTable.Meta): diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index e601bd0cc..a319fc7ad 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -1,9 +1,10 @@ -from collections import namedtuple from dataclasses import dataclass from typing import Optional import django_tables2 as tables from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.template import Context, Template from django.urls import reverse from django.utils.safestring import mark_safe from django_tables2.utils import Accessor @@ -14,7 +15,6 @@ from utilities.utils import content_type_identifier, content_type_name __all__ = ( 'ActionsColumn', 'BooleanColumn', - 'ButtonsColumn', 'ChoiceFieldColumn', 'ColorColumn', 'ColoredLabelColumn', @@ -100,7 +100,14 @@ class ActionsItem: class ActionsColumn(tables.Column): - attrs = {'td': {'class': 'text-end noprint'}} + """ + A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include + additional buttons rendered from a template string. + + :param sequence: The ordered list of dropdown menu items to include + :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + """ + attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () actions = { 'edit': ActionsItem('Edit', 'pencil', 'change'), @@ -108,12 +115,10 @@ class ActionsColumn(tables.Column): 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, extra_actions=None, sequence=('edit', 'delete', 'changelog'), **kwargs): + def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): super().__init__(*args, **kwargs) - # Add/update any extra actions passed - if extra_actions: - self.actions.update(extra_actions) + self.extra_buttons = extra_buttons # Determine which actions to enable self.actions = { @@ -134,9 +139,10 @@ class ActionsColumn(tables.Column): url_appendix = f'?return_url={request.path}' if request else '' links = [] + user = getattr(request, 'user', AnonymousUser()) for action, attrs in self.actions.items(): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' - if attrs.permission is None or request.user.has_perm(permission): + if attrs.permission is None or user.has_perm(permission): url = reverse(f'{viewname_base}_{action}', kwargs={'pk': record.pk}) links.append(f'
  • ' f' {attrs.title}
  • ') @@ -144,68 +150,21 @@ class ActionsColumn(tables.Column): if not links: return '' - menu = f'' + f'' + + # Render any extra buttons from template code + if self.extra_buttons: + template = Template(self.extra_buttons) + context = getattr(table, "context", Context()) + context.update({'record': record}) + menu = template.render(context) + menu return mark_safe(menu) -class ButtonsColumn(tables.TemplateColumn): - """ - Render edit, delete, and changelog buttons for an object. - - :param model: Model class to use for calculating URL view names - :param prepend_content: Additional template content to render in the column (optional) - """ - buttons = ('changelog', 'edit', 'delete') - attrs = {'td': {'class': 'text-end text-nowrap noprint'}} - # Note that braces are escaped to allow for string formatting prior to template rendering - template_code = """ - {{% if "changelog" in buttons %}} - - - - {{% endif %}} - {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - - - - {{% endif %}} - {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} - - - - {{% endif %}} - """ - - def __init__(self, model, *args, buttons=None, prepend_template=None, **kwargs): - if prepend_template: - prepend_template = prepend_template.replace('{', '{{') - prepend_template = prepend_template.replace('}', '}}') - self.template_code = prepend_template + self.template_code - - template_code = self.template_code.format( - app_label=model._meta.app_label, - model_name=model._meta.model_name, - buttons=buttons - ) - - super().__init__(template_code=template_code, *args, **kwargs) - - # Exclude from export by default - if 'exclude_from_export' not in kwargs: - self.exclude_from_export = True - - self.extra_context.update({ - 'buttons': buttons or self.buttons, - }) - - def header(self): - return '' - - class ChoiceFieldColumn(tables.Column): """ Render a ChoiceField value inside a indicating a particular CSS class. This is useful for displaying colored diff --git a/netbox/utilities/tests/test_tables.py b/netbox/utilities/tests/test_tables.py index 119587ff8..55a5e4cc7 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/utilities/tests/test_tables.py @@ -30,7 +30,8 @@ class TagColumnTest(TestCase): def test_tagcolumn(self): template = Template('{% load render_table from django_tables2 %}{% render_table table %}') + table = TagColumnTable(Site.objects.all(), orderable=False) context = Context({ - 'table': TagColumnTable(Site.objects.all(), orderable=False) + 'table': table }) template.render(context) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 65f9f1257..0588f51a5 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -3,7 +3,7 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, + ActionsColumn, BaseTable, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -183,10 +183,9 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): bridge = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=VMInterface, - buttons=('edit', 'delete'), - prepend_template=VMINTERFACE_BUTTONS + actions = ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=VMINTERFACE_BUTTONS ) class Meta(BaseTable.Meta): From 94c116617a67ac7b8c72b877f7506c367f05b0f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 11:20:06 -0500 Subject: [PATCH 081/271] Changelog for #7679 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6240016cf..d85ad17e2 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -57,6 +57,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation +* [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts From 72e17914e20845161ac41791c4af2b88d63dbe8e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 12:11:37 -0500 Subject: [PATCH 082/271] Closes #8296: Allow disabling custom links --- docs/models/extras/customlink.md | 2 +- docs/release-notes/version-3.2.md | 3 +++ netbox/extras/api/serializers.py | 2 +- netbox/extras/filtersets.py | 4 +++- netbox/extras/forms/bulk_edit.py | 4 ++++ netbox/extras/forms/bulk_import.py | 3 ++- netbox/extras/forms/filtersets.py | 12 +++++++++--- netbox/extras/forms/models.py | 2 +- .../migrations/0070_customlink_enabled.py | 18 ++++++++++++++++++ netbox/extras/models/models.py | 3 +++ netbox/extras/tables.py | 5 +++-- netbox/extras/templatetags/custom_links.py | 2 +- netbox/extras/tests/test_api.py | 7 +++++++ netbox/extras/tests/test_filtersets.py | 9 +++++++++ netbox/extras/tests/test_views.py | 16 +++++++++------- netbox/templates/extras/customlink.html | 4 ++++ netbox/utilities/tables/tables.py | 19 ++++++++++--------- 17 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 netbox/extras/migrations/0070_customlink_enabled.py diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 7fd510841..96ff0bbf7 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as: View NMS ``` -Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually. !!! warning Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e0ef639fa..31025bb85 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -64,6 +64,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group +* [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links ### Other Changes @@ -106,6 +107,8 @@ Inventory item templates can be arranged hierarchically within a device type, an * Add `cluster_types` field * extras.CustomField * Added `object_type` field +* extras.CustomLink + * Added `enabled` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint * Added the `min_vid` and `max_vid` fields diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index fa0e5189f..6279ea2b7 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -101,7 +101,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): class Meta: model = CustomLink fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', + 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bf25ff76c..a839e2dd3 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet): class Meta: model = CustomLink - fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + fields = [ + 'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 1b87256a5..56b51c894 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm): limit_choices_to=FeatureQuery('custom_fields'), required=False ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) new_window = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 9f44494e0..fa6d8af55 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm): class Meta: model = CustomLink fields = ( - 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', + 'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', + 'link_url', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 388cd1e60..330bb91e3 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm): field_groups = [ ['q'], - ['content_type', 'weight', 'new_window'], + ['content_type', 'enabled', 'new_window', 'weight'], ] content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False ) - weight = forms.IntegerField( - required=False + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) new_window = forms.NullBooleanField( required=False, @@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + weight = forms.IntegerField( + required=False + ) class ExportTemplateFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 55e58a7f2..ca2c6b900 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -53,7 +53,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): model = CustomLink fields = '__all__' fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')), + ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Templates', ('link_text', 'link_url')), ) widgets = { diff --git a/netbox/extras/migrations/0070_customlink_enabled.py b/netbox/extras/migrations/0070_customlink_enabled.py new file mode 100644 index 000000000..839a4dba5 --- /dev/null +++ b/netbox/extras/migrations/0070_customlink_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-10 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0069_custom_object_field'), + ] + + operations = [ + migrations.AddField( + model_name='customlink', + name='enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ac3a23410..3612b2a6f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -192,6 +192,9 @@ class CustomLink(ChangeLoggedModel): max_length=100, unique=True ) + enabled = models.BooleanField( + default=True + ) link_text = models.CharField( max_length=500, help_text="Jinja2 template code for link text" diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 071caa354..adfccb575 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -73,15 +73,16 @@ class CustomLinkTable(BaseTable): linkify=True ) content_type = ContentTypeColumn() + enabled = BooleanColumn() new_window = BooleanColumn() class Meta(BaseTable.Meta): model = CustomLink fields = ( - 'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', + 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window', ) - default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window') + default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') # diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 32ec966b3..dd5467338 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -36,7 +36,7 @@ def custom_links(context, obj): Render all applicable links for the given object. """ content_type = ContentType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(content_type=content_type) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index d15b57e43..d790eff71 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): { 'content_type': 'dcim.site', 'name': 'Custom Link 4', + 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 5', + 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 6', + 'enabled': False, 'link_text': 'Link 6', 'link_url': 'http://example.com/?6', }, ] bulk_update_data = { 'new_window': True, + 'enabled': False, } @classmethod @@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): CustomLink( content_type=site_ct, name='Custom Link 1', + enabled=True, link_text='Link 1', link_url='http://example.com/?1', ), CustomLink( content_type=site_ct, name='Custom Link 2', + enabled=True, link_text='Link 2', link_url='http://example.com/?2', ), CustomLink( content_type=site_ct, name='Custom Link 3', + enabled=False, link_text='Link 3', link_url='http://example.com/?3', ), diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index a5f77afa9..3a08055cb 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 1', content_type=content_types[0], + enabled=True, weight=100, new_window=False, link_text='Link 1', @@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 2', content_type=content_types[1], + enabled=True, weight=200, new_window=False, link_text='Link 1', @@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 3', content_type=content_types[2], + enabled=False, weight=300, new_window=True, link_text='Link 1', @@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): params = {'weight': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_new_window(self): params = {'new_window': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 34d5cb67e..ea3a952d6 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): site_ct = ContentType.objects.get_for_model(Site) CustomLink.objects.bulk_create(( - CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'), - CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'), - CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'), + CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'), + CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'), + CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'), )) cls.form_data = { 'name': 'Custom Link X', 'content_type': site_ct.pk, + 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, 'link_text': 'Link X', @@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_type,weight,button_class,link_text,link_url", - "Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4", - "Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5", - "Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6", + "name,content_type,enabled,weight,button_class,link_text,link_url", + "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", + "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", + "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", ) cls.bulk_edit_data = { 'button_class': CustomLinkButtonClassChoices.CYAN, + 'enabled': False, 'weight': 200, } diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index ebf50882c..1f3866182 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -19,6 +19,10 @@ Content Type {{ object.content_type }} + + Enabled + {% checkmark object.enabled %} + Group Name {{ object.group_name|placeholder }} diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py index 6c3b56959..d1915569e 100644 --- a/netbox/utilities/tables/tables.py +++ b/netbox/utilities/tables/tables.py @@ -35,15 +35,16 @@ class BaseTable(tables.Table): if extra_columns is None: extra_columns = [] - # Add custom field columns - obj_type = ContentType.objects.get_for_model(self._meta.model) - cf_columns = [ - (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) - ] - cl_columns = [ - (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) - ] - extra_columns.extend([*cf_columns, *cl_columns]) + # Add custom field & custom link columns + content_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = CustomField.objects.filter(content_types=content_type) + extra_columns.extend([ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields + ]) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + extra_columns.extend([ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links + ]) super().__init__(*args, extra_columns=extra_columns, **kwargs) From 21e0e6e4959e1f0b791be342b16cf1e2aa48f693 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 14:03:07 -0500 Subject: [PATCH 083/271] Closes #6954: Remember users' table ordering preferences --- docs/development/user-preferences.md | 13 +++++----- docs/release-notes/version-3.2.md | 1 + netbox/circuits/views.py | 8 +++--- netbox/dcim/views.py | 16 ++++++------ netbox/extras/views.py | 8 +++--- netbox/ipam/views.py | 12 ++++----- netbox/netbox/views/generic/object_views.py | 7 +++--- netbox/tenancy/views.py | 10 ++++---- netbox/users/tests/test_preferences.py | 27 ++++++++++++++++++++- netbox/utilities/tables/__init__.py | 19 +++++++++++---- netbox/virtualization/views.py | 6 ++--- netbox/wireless/views.py | 6 ++--- 12 files changed, 84 insertions(+), 49 deletions(-) diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index a707eb6ad..622fbb4b9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,9 +4,10 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -|-------------------------|-------------| -| data_format | Preferred format when rendering raw data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.${table}.columns | The ordered list of columns to display when viewing the table | -| ui.colormode | Light or dark mode in the user interface | +| Name | Description | +|--------------------------|---------------------------------------------------------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| tables.${table}.ordering | A list of column names by which the table should be ordered | +| ui.colormode | Light or dark mode in the user interface | diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 31025bb85..a2bc5988e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -56,6 +56,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements +* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2f1addab1..97e985dcd 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404, redirect, render from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .choices import CircuitTerminationSideChoices @@ -35,7 +35,7 @@ class ProviderView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -96,7 +96,7 @@ class ProviderNetworkView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -150,7 +150,7 @@ class CircuitTypeView(generic.ObjectView): def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) circuits_table = tables.CircuitTable(circuits, exclude=('type',)) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e64124539..a85fc7438 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine @@ -165,7 +165,7 @@ class RegionView(generic.ObjectView): region=instance ) sites_table = tables.SiteTable(sites, exclude=('region',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_regions_table': child_regions_table, @@ -250,7 +250,7 @@ class SiteGroupView(generic.ObjectView): group=instance ) sites_table = tables.SiteTable(sites, exclude=('group',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_groups_table': child_groups_table, @@ -422,7 +422,7 @@ class LocationView(generic.ObjectView): cumulative=True ).filter(pk__in=location_ids).exclude(pk=instance.pk) child_locations_table = tables.LocationTable(child_locations) - paginate_table(child_locations_table, request) + configure_table(child_locations_table, request) return { 'rack_count': rack_count, @@ -493,7 +493,7 @@ class RackRoleView(generic.ObjectView): ) racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) - paginate_table(racks_table, request) + configure_table(racks_table, request) return { 'racks_table': racks_table, @@ -743,7 +743,7 @@ class ManufacturerView(generic.ObjectView): ) devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',)) - paginate_table(devicetypes_table, request) + configure_table(devicetypes_table, request) return { 'devicetypes_table': devicetypes_table, @@ -1439,7 +1439,7 @@ class DeviceRoleView(generic.ObjectView): device_role=instance ) devices_table = tables.DeviceTable(devices, exclude=('device_role',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, @@ -1503,7 +1503,7 @@ class PlatformView(generic.ObjectView): platform=instance ) devices_table = tables.DeviceTable(devices, exclude=('platform',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0df4d6905..59f922d82 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables @@ -215,7 +215,7 @@ class TagView(generic.ObjectView): data=tagged_items, orderable=False ) - paginate_table(taggeditem_table, request) + configure_table(taggeditem_table, request) object_types = [ { @@ -451,7 +451,7 @@ class ObjectChangeLogView(View): data=objectchanges, orderable=False ) - paginate_table(objectchanges_table, request) + configure_table(objectchanges_table, request) # Default to using "/.html" as the template, if it exists. Otherwise, # fall back to using base.html. @@ -571,7 +571,7 @@ class ObjectJournalView(View): assigned_object_id=obj.pk ) journalentry_table = tables.ObjectJournalTable(journalentries) - paginate_table(journalentry_table, request) + configure_table(journalentry_table, request) if request.user.has_perm('extras.add_journalentry'): form = forms.JournalEntryForm( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1f20e886f..23d6eb2a7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from dcim.tables import SiteTable from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface @@ -161,7 +161,7 @@ class RIRView(generic.ObjectView): rir=instance ) aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) - paginate_table(aggregates_table, request) + configure_table(aggregates_table, request) return { 'aggregates_table': aggregates_table, @@ -219,7 +219,7 @@ class ASNView(generic.ObjectView): def get_extra_context(self, request, instance): sites = instance.sites.restrict(request.user, 'view') sites_table = SiteTable(sites) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'sites_table': sites_table, @@ -356,7 +356,7 @@ class RoleView(generic.ObjectView): ) prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) - paginate_table(prefixes_table, request) + configure_table(prefixes_table, request) return { 'prefixes_table': prefixes_table, @@ -664,7 +664,7 @@ class IPAddressView(generic.ObjectView): vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) ) related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - paginate_table(related_ips_table, request) + configure_table(related_ips_table, request) return { 'parent_prefixes_table': parent_prefixes_table, @@ -800,7 +800,7 @@ class VLANGroupView(generic.ObjectView): vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes')) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') - paginate_table(vlans_table, request) + configure_table(vlans_table, request) # Compile permissions list for rendering the object table permissions = { diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index d8850391b..f5e315801 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -15,7 +15,6 @@ from django.utils.safestring import mark_safe from django.views.generic import View from django_tables2.export import TableExport -from dcim.forms.object_create import ComponentCreateForm from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror @@ -23,7 +22,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin @@ -135,7 +134,7 @@ class ObjectChildrenView(ObjectView): # Determine whether to display bulk action checkboxes if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') - paginate_table(table, request) + configure_table(table, request) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): @@ -284,7 +283,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): # Render the objects table table = self.get_table(request, permissions) - paginate_table(table, request) + configure_table(table, request) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b41af62ee..d634292ec 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -6,7 +6,7 @@ from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables @@ -38,7 +38,7 @@ class TenantGroupView(generic.ObjectView): group=instance ) tenants_table = tables.TenantTable(tenants, exclude=('group',)) - paginate_table(tenants_table, request) + configure_table(tenants_table, request) return { 'tenants_table': tenants_table, @@ -184,7 +184,7 @@ class ContactGroupView(generic.ObjectView): group=instance ) contacts_table = tables.ContactTable(contacts, exclude=('group',)) - paginate_table(contacts_table, request) + configure_table(contacts_table, request) return { 'child_groups_table': child_groups_table, @@ -251,7 +251,7 @@ class ContactRoleView(generic.ObjectView): ) contacts_table = tables.ContactAssignmentTable(contact_assignments) contacts_table.columns.hide('role') - paginate_table(contacts_table, request) + configure_table(contacts_table, request) return { 'contacts_table': contacts_table, @@ -308,7 +308,7 @@ class ContactView(generic.ObjectView): ) assignments_table = tables.ContactAssignmentTable(contact_assignments) assignments_table.columns.hide('contact') - paginate_table(assignments_table, request) + configure_table(assignments_table, request) return { 'assignments_table': assignments_table, diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index 23e94e8ef..035ca6840 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -1,7 +1,13 @@ from django.contrib.auth.models import User -from django.test import override_settings, TestCase +from django.test import override_settings +from django.test.client import RequestFactory +from django.urls import reverse +from dcim.models import Site +from dcim.tables import SiteTable from users.preferences import UserPreference +from utilities.tables import configure_table +from utilities.testing import TestCase DEFAULT_USER_PREFERENCES = { @@ -12,6 +18,7 @@ DEFAULT_USER_PREFERENCES = { class UserPreferencesTest(TestCase): + user_permissions = ['dcim.view_site'] def test_userpreference(self): CHOICES = ( @@ -37,3 +44,21 @@ class UserPreferencesTest(TestCase): userconfig = user.config self.assertEqual(userconfig.data, DEFAULT_USER_PREFERENCES) + + def test_table_ordering(self): + url = reverse('dcim:site_list') + response = self.client.get(f"{url}?sort=status") + self.assertEqual(response.status_code, 200) + + # Check that table ordering preference has been recorded + self.user.refresh_from_db() + ordering = self.user.config.get(f'tables.SiteTable.ordering') + self.assertEqual(ordering, ['status']) + + # Check that a recorded preference is honored by default + self.user.config.set(f'tables.SiteTable.ordering', ['-status'], commit=True) + table = SiteTable(Site.objects.all()) + request = RequestFactory().get(url) + request.user = self.user + configure_table(table, request) + self.assertEqual(table.order_by, ('-status',)) diff --git a/netbox/utilities/tables/__init__.py b/netbox/utilities/tables/__init__.py index 37dd75144..25fa95296 100644 --- a/netbox/utilities/tables/__init__.py +++ b/netbox/utilities/tables/__init__.py @@ -5,14 +5,23 @@ from .columns import * from .tables import * -# -# Pagination -# - -def paginate_table(table, request): +def configure_table(table, request): """ Paginate a table given a request context. """ + # Save ordering preference + if request.user.is_authenticated: + table_name = table.__class__.__name__ + if table.prefixed_order_by_field in request.GET: + # If an ordering has been specified as a query parameter, save it as the + # user's preferred ordering for this table. + ordering = request.GET.getlist(table.prefixed_order_by_field) + request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): + # If no ordering has been specified, set the preferred ordering (if any). + table.order_by = ordering + + # Paginate the table results paginate = { 'paginator_class': EnhancedPaginator, 'per_page': get_paginate_count(request) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 742d6d9ea..0fc8c9bf7 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,7 +11,7 @@ from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -41,7 +41,7 @@ class ClusterTypeView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('type',)) - paginate_table(clusters_table, request) + configure_table(clusters_table, request) return { 'clusters_table': clusters_table, @@ -103,7 +103,7 @@ class ClusterGroupView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('group',)) - paginate_table(clusters_table, request) + configure_table(clusters_table, request) return { 'clusters_table': clusters_table, diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index dd1e760bb..443cf8eef 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,6 +1,6 @@ from dcim.models import Interface from netbox.views import generic -from utilities.tables import paginate_table +from utilities.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import * @@ -31,7 +31,7 @@ class WirelessLANGroupView(generic.ObjectView): group=instance ) wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) - paginate_table(wirelesslans_table, request) + configure_table(wirelesslans_table, request) return { 'wirelesslans_table': wirelesslans_table, @@ -99,7 +99,7 @@ class WirelessLANView(generic.ObjectView): wireless_lans=instance ) interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) - paginate_table(interfaces_table, request) + configure_table(interfaces_table, request) return { 'interfaces_table': interfaces_table, From ff396b595370a3901eb9c605756c7328ba2785fe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 10 Jan 2022 14:27:52 -0500 Subject: [PATCH 084/271] Fix CSV import test & form cleanup --- netbox/dcim/forms/bulk_import.py | 1 + netbox/dcim/forms/models.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index ef8d79082..acce43be0 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -620,6 +620,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): ) duplex = CSVChoiceField( choices=InterfaceDuplexChoices, + required=False, help_text='Duplex' ) mode = CSVChoiceField( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 07fa07e12..378a567fc 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1292,10 +1292,11 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), + 'speed': SelectSpeedWidget(), + 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), - 'speed': SelectSpeedWidget(attrs={'readonly': None}), } labels = { 'mode': '802.1Q Mode', From 5cbc978cad63447af7f964989c2b742b9eb4e884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20R=C3=B8dvand?= Date: Wed, 12 Jan 2022 21:58:19 +0100 Subject: [PATCH 085/271] Render the payload_url of the Webhook with Jinja2 - Update markdown documentation - Expand on the help text for the Webhook model --- docs/models/extras/webhook.md | 6 +++--- netbox/extras/models/models.py | 9 ++++++++- netbox/extras/webhooks_worker.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index c71657336..e256a66f4 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -3,7 +3,7 @@ A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks. !!! warning - Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. + Webhooks support the inclusion of user-submitted code to generate URL, custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. ## Configuration @@ -12,7 +12,7 @@ A webhook is a mechanism for conveying to some external system a change that too * **Enabled** - If unchecked, the webhook will be inactive. * **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected. * **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. -* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed. +* **URL** - The fully-qualified URL of the request to be sent. This may specify a destination port number if needed. Jinja2 templating is supported for this field. * **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`) * **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). * **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) @@ -23,7 +23,7 @@ A webhook is a mechanism for conveying to some external system a change that too ## Jinja2 Template Support -[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. +[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `URL`, `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration: diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 3612b2a6f..ab877b99e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -68,7 +68,8 @@ class Webhook(ChangeLoggedModel): payload_url = models.CharField( max_length=500, verbose_name='URL', - help_text="A POST will be sent to this URL when the webhook is called." + help_text='This URL will be called using the HTTP method defined when the webhook is called. ' + 'Jinja2 template processing is supported with the same context as the request body.' ) enabled = models.BooleanField( default=True @@ -176,6 +177,12 @@ class Webhook(ChangeLoggedModel): else: return json.dumps(context, cls=JSONEncoder) + def render_payload_url(self, context): + """ + Render the payload URL. + """ + return render_jinja2(self.payload_url, context) + @extras_features('webhooks', 'export_templates') class CustomLink(ChangeLoggedModel): diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 1f0a66b8a..7e8965182 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -67,7 +67,7 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user # Prepare the HTTP request params = { 'method': webhook.http_method, - 'url': webhook.payload_url, + 'url': webhook.render_payload_url(context), 'headers': headers, 'data': body.encode('utf8'), } From 97e7ef9a3fde70450bff97c3b43798c090a21d71 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 12 Jan 2022 16:42:28 -0500 Subject: [PATCH 086/271] Introduce ServiceTemplate --- docs/core-functionality/services.md | 1 + docs/models/ipam/servicetemplate.md | 3 + netbox/ipam/api/nested_serializers.py | 9 +++ netbox/ipam/api/serializers.py | 12 +++ netbox/ipam/api/urls.py | 1 + netbox/ipam/api/views.py | 8 +- netbox/ipam/filtersets.py | 23 ++++++ netbox/ipam/forms/bulk_edit.py | 12 ++- netbox/ipam/forms/bulk_import.py | 12 +++ netbox/ipam/forms/filtersets.py | 9 ++- netbox/ipam/forms/models.py | 22 ++++++ netbox/ipam/graphql/schema.py | 3 + netbox/ipam/graphql/types.py | 9 +++ .../ipam/migrations/0055_servicetemplate.py | 33 +++++++++ netbox/ipam/models/__init__.py | 1 + netbox/ipam/models/services.py | 74 ++++++++++++------- netbox/ipam/tables/services.py | 26 +++++-- netbox/ipam/tests/test_api.py | 35 +++++++++ netbox/ipam/tests/test_filtersets.py | 29 ++++++++ netbox/ipam/tests/test_views.py | 35 +++++++++ netbox/ipam/urls.py | 12 +++ netbox/ipam/views.py | 51 ++++++++++++- netbox/netbox/navigation_menu.py | 1 + netbox/templates/ipam/servicetemplate.html | 46 ++++++++++++ 24 files changed, 427 insertions(+), 40 deletions(-) create mode 100644 docs/models/ipam/servicetemplate.md create mode 100644 netbox/ipam/migrations/0055_servicetemplate.py create mode 100644 netbox/templates/ipam/servicetemplate.html diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md index 2e7aaf65a..316c7fe00 100644 --- a/docs/core-functionality/services.md +++ b/docs/core-functionality/services.md @@ -1,3 +1,4 @@ # Service Mapping +{!models/ipam/servicetemplate.md!} {!models/ipam/service.md!} diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md new file mode 100644 index 000000000..7fed40211 --- /dev/null +++ b/docs/models/ipam/servicetemplate.md @@ -0,0 +1,3 @@ +# Service Templates + +Service templates can be used to instantiate services on devices and virtual machines. A template defines a name, protocol, and port number(s), and may optionally include a description. Services can be instantiated from templates and applied to devices and/or virtual machines, and may be associated with specific IP addresses. diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 1eb66743b..5f9e09049 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -15,6 +15,7 @@ __all__ = [ 'NestedRoleSerializer', 'NestedRouteTargetSerializer', 'NestedServiceSerializer', + 'NestedServiceTemplateSerializer', 'NestedVLANGroupSerializer', 'NestedVLANSerializer', 'NestedVRFSerializer', @@ -175,6 +176,14 @@ class NestedIPAddressSerializer(WritableNestedSerializer): # Services # +class NestedServiceTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail') + + class Meta: + model = models.ServiceTemplate + fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] + + class NestedServiceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c028a3d5d..f71d3958a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -403,6 +403,18 @@ class AvailableIPSerializer(serializers.Serializer): # Services # +class ServiceTemplateSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail') + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) + + class Meta: + model = ServiceTemplate + fields = [ + 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', + ] + + class ServiceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') device = NestedDeviceSerializer(required=False, allow_null=True) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 3d69e258e..8a68db9be 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -42,6 +42,7 @@ router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) # Services +router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) app_name = 'ipam-api' diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index de415cd81..357937855 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -140,7 +140,13 @@ class VLANViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VLANFilterSet -class ServiceViewSet(ModelViewSet): +class ServiceTemplateViewSet(CustomFieldModelViewSet): + queryset = ServiceTemplate.objects.prefetch_related('tags') + serializer_class = serializers.ServiceTemplateSerializer + filterset_class = filtersets.ServiceTemplateFilterSet + + +class ServiceViewSet(CustomFieldModelViewSet): queryset = Service.objects.prefetch_related( 'device', 'virtual_machine', 'tags', 'ipaddresses' ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 8a10a7b24..52e4499c7 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -29,6 +29,7 @@ __all__ = ( 'RoleFilterSet', 'RouteTargetFilterSet', 'ServiceFilterSet', + 'ServiceTemplateFilterSet', 'VLANFilterSet', 'VLANGroupFilterSet', 'VRFFilterSet', @@ -854,6 +855,28 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.get_for_virtualmachine(value) +class ServiceTemplateFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + port = NumericArrayFilter( + field_name='ports', + lookup_expr='contains' + ) + tag = TagFilter() + + class Meta: + model = ServiceTemplate + fields = ['id', 'name', 'protocol'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(name__icontains=value) | Q(description__icontains=value) + return queryset.filter(qs_filter) + + class ServiceFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 1e25a1090..308a467d1 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -23,6 +23,7 @@ __all__ = ( 'RoleBulkEditForm', 'RouteTargetBulkEditForm', 'ServiceBulkEditForm', + 'ServiceTemplateBulkEditForm', 'VLANBulkEditForm', 'VLANGroupBulkEditForm', 'VRFBulkEditForm', @@ -433,9 +434,9 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=Service.objects.all(), + queryset=ServiceTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) protocol = forms.ChoiceField( @@ -459,3 +460,10 @@ class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = [ 'description', ] + + +class ServiceBulkEditForm(ServiceTemplateBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Service.objects.all(), + widget=forms.MultipleHiddenInput() + ) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index a4fdaa3ae..1ae977fe5 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -21,6 +21,7 @@ __all__ = ( 'RoleCSVForm', 'RouteTargetCSVForm', 'ServiceCSVForm', + 'ServiceTemplateCSVForm', 'VLANCSVForm', 'VLANGroupCSVForm', 'VRFCSVForm', @@ -392,6 +393,17 @@ class VLANCSVForm(CustomFieldModelCSVForm): } +class ServiceTemplateCSVForm(CustomFieldModelCSVForm): + protocol = CSVChoiceField( + choices=ServiceProtocolChoices, + help_text='IP protocol' + ) + + class Meta: + model = ServiceTemplate + fields = ('name', 'protocol', 'ports', 'description') + + class ServiceCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index df95bdd05..9bfb1df10 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -24,6 +24,7 @@ __all__ = ( 'RoleFilterForm', 'RouteTargetFilterForm', 'ServiceFilterForm', + 'ServiceTemplateFilterForm', 'VLANFilterForm', 'VLANGroupFilterForm', 'VRFFilterForm', @@ -447,8 +448,8 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class ServiceFilterForm(CustomFieldModelFilterForm): - model = Service +class ServiceTemplateFilterForm(CustomFieldModelFilterForm): + model = ServiceTemplate field_groups = ( ('q', 'tag'), ('protocol', 'port'), @@ -462,3 +463,7 @@ class ServiceFilterForm(CustomFieldModelFilterForm): required=False, ) tag = TagFilterField(model) + + +class ServiceFilterForm(ServiceTemplateFilterForm): + model = Service diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index cef877245..9aabd4d54 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -31,6 +31,7 @@ __all__ = ( 'RoleForm', 'RouteTargetForm', 'ServiceForm', + 'ServiceTemplateForm', 'VLANForm', 'VLANGroupForm', 'VRFForm', @@ -815,6 +816,27 @@ class VLANForm(TenancyForm, CustomFieldModelForm): } +class ServiceTemplateForm(CustomFieldModelForm): + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), + help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ServiceTemplate + fields = ('name', 'protocol', 'ports', 'description', 'tags') + widgets = { + 'protocol': StaticSelect(), + } + + class ServiceForm(CustomFieldModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 9609d1434..f466c1857 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -32,6 +32,9 @@ class IPAMQuery(graphene.ObjectType): service = ObjectField(ServiceType) service_list = ObjectListField(ServiceType) + service_template = ObjectField(ServiceTemplateType) + service_template_list = ObjectListField(ServiceTemplateType) + fhrp_group = ObjectField(FHRPGroupType) fhrp_group_list = ObjectListField(FHRPGroupType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index d9aec66b3..8dd122a0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -16,6 +16,7 @@ __all__ = ( 'RoleType', 'RouteTargetType', 'ServiceType', + 'ServiceTemplateType', 'VLANType', 'VLANGroupType', 'VRFType', @@ -120,6 +121,14 @@ class ServiceType(PrimaryObjectType): filterset_class = filtersets.ServiceFilterSet +class ServiceTemplateType(PrimaryObjectType): + + class Meta: + model = models.ServiceTemplate + fields = '__all__' + filterset_class = filtersets.ServiceTemplateFilterSet + + class VLANType(PrimaryObjectType): class Meta: diff --git a/netbox/ipam/migrations/0055_servicetemplate.py b/netbox/ipam/migrations/0055_servicetemplate.py new file mode 100644 index 000000000..738317907 --- /dev/null +++ b/netbox/ipam/migrations/0055_servicetemplate.py @@ -0,0 +1,33 @@ +import django.contrib.postgres.fields +import django.core.serializers.json +import django.core.validators +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0070_customlink_enabled'), + ('ipam', '0054_vlangroup_min_max_vids'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceTemplate', + 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)), + ('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)), + ('description', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=100, unique=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('name',), + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index ab0e4b6ca..1857b7d66 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -16,6 +16,7 @@ __all__ = ( 'Role', 'RouteTarget', 'Service', + 'ServiceTemplate', 'VLAN', 'VLANGroup', 'VRF', diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 5c1ebb9dd..43f8353bc 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -13,11 +13,59 @@ from utilities.utils import array_to_string __all__ = ( 'Service', + 'ServiceTemplate', ) +class ServiceBase(models.Model): + protocol = models.CharField( + max_length=50, + choices=ServiceProtocolChoices + ) + ports = ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ] + ), + verbose_name='Port numbers' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + abstract = True + + def __str__(self): + return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' + + @property + def port_list(self): + return array_to_string(self.ports) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Service(PrimaryModel): +class ServiceTemplate(ServiceBase, PrimaryModel): + """ + A template for a Service to be applied to a device or virtual machine. + """ + name = models.CharField( + max_length=100, + unique=True + ) + + class Meta: + ordering = ('name',) + + def get_absolute_url(self): + return reverse('ipam:servicetemplate', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Service(ServiceBase, PrimaryModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. @@ -40,36 +88,16 @@ class Service(PrimaryModel): name = models.CharField( max_length=100 ) - protocol = models.CharField( - max_length=50, - choices=ServiceProtocolChoices - ) - ports = ArrayField( - base_field=models.PositiveIntegerField( - validators=[ - MinValueValidator(SERVICE_PORT_MIN), - MaxValueValidator(SERVICE_PORT_MAX) - ] - ), - verbose_name='Port numbers' - ) ipaddresses = models.ManyToManyField( to='ipam.IPAddress', related_name='services', blank=True, verbose_name='IP addresses' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique - def __str__(self): - return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' - def get_absolute_url(self): return reverse('ipam:service', args=[self.pk]) @@ -85,7 +113,3 @@ class Service(PrimaryModel): raise ValidationError("A service cannot be associated with both a device and a virtual machine.") if not self.device and not self.virtual_machine: raise ValidationError("A service must be associated with either a device or a virtual machine.") - - @property - def port_list(self): - return array_to_string(self.ports) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index ff6b766f7..783cb3537 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -5,12 +5,27 @@ from ipam.models import * __all__ = ( 'ServiceTable', + 'ServiceTemplateTable', ) -# -# Services -# +class ServiceTemplateTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + ports = tables.Column( + accessor=tables.A('port_list') + ) + tags = TagColumn( + url_name='ipam:servicetemplate_list' + ) + + class Meta(BaseTable.Meta): + model = ServiceTemplate + fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags') + default_columns = ('pk', 'name', 'protocol', 'ports', 'description') + class ServiceTable(BaseTable): pk = ToggleColumn() @@ -21,9 +36,8 @@ class ServiceTable(BaseTable): linkify=True, order_by=('device', 'virtual_machine') ) - ports = tables.TemplateColumn( - template_code='{{ record.port_list }}', - verbose_name='Ports' + ports = tables.Column( + accessor=tables.A('port_list') ) tags = TagColumn( url_name='ipam:service_list' diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index dfbf1a971..d99de6d20 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -832,6 +832,41 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.assertTrue(content['detail'].startswith('Unable to delete object.')) +class ServiceTemplateTest(APIViewTestCases.APIViewTestCase): + model = ServiceTemplate + brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + service_templates = ( + ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1, 2]), + ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3, 4]), + ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[5, 6]), + ) + ServiceTemplate.objects.bulk_create(service_templates) + + cls.create_data = [ + { + 'name': 'Service Template 4', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'ports': [7, 8], + }, + { + 'name': 'Service Template 5', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'ports': [9, 10], + }, + { + 'name': 'Service Template 6', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'ports': [11, 12], + }, + ] + + class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 773737dea..d673628af 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1307,6 +1307,35 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global +class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ServiceTemplate.objects.all() + filterset = ServiceTemplateFilterSet + + @classmethod + def setUpTestData(cls): + service_templates = ( + ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]), + ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]), + ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]), + ServiceTemplate(name='Service Template 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]), + ServiceTemplate(name='Service Template 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]), + ServiceTemplate(name='Service Template 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]), + ) + ServiceTemplate.objects.bulk_create(service_templates) + + def test_name(self): + params = {'name': ['Service Template 1', 'Service Template 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_protocol(self): + params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_port(self): + params = {'port': '1001'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Service.objects.all() filterset = ServiceFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 80088eb73..928a8b1c8 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -641,6 +641,41 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ServiceTemplate + + @classmethod + def setUpTestData(cls): + ServiceTemplate.objects.bulk_create([ + ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), + ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), + ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Service Template X', + 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, + 'ports': '104,105', + 'description': 'A new service template', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,protocol,ports,description", + "Service Template 4,tcp,1,First service template", + "Service Template 5,tcp,2,Second service template", + "Service Template 6,tcp,3,Third service template", + ) + + cls.bulk_edit_data = { + 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, + 'ports': '106,107', + 'description': 'New description', + } + + class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Service diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index a9f420253..fe8cfd150 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -162,6 +162,18 @@ urlpatterns = [ path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), path('vlans//journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}), + # Service templates + path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), + path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'), + path('service-templates/import/', views.ServiceTemplateBulkImportView.as_view(), name='servicetemplate_import'), + path('service-templates/edit/', views.ServiceTemplateBulkEditView.as_view(), name='servicetemplate_bulk_edit'), + path('service-templates/delete/', views.ServiceTemplateBulkDeleteView.as_view(), name='servicetemplate_bulk_delete'), + 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}), + # Services path('services/', views.ServiceListView.as_view(), name='service_list'), path('services/add/', views.ServiceEditView.as_view(), name='service_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 39706e9cc..f5aa0a7d7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1028,6 +1028,49 @@ class VLANBulkDeleteView(generic.BulkDeleteView): table = tables.VLANTable +# +# Service templates +# + +class ServiceTemplateListView(generic.ObjectListView): + queryset = ServiceTemplate.objects.all() + filterset = filtersets.ServiceTemplateFilterSet + filterset_form = forms.ServiceTemplateFilterForm + table = tables.ServiceTemplateTable + + +class ServiceTemplateView(generic.ObjectView): + queryset = ServiceTemplate.objects.all() + + +class ServiceTemplateEditView(generic.ObjectEditView): + queryset = ServiceTemplate.objects.all() + model_form = forms.ServiceTemplateForm + + +class ServiceTemplateDeleteView(generic.ObjectDeleteView): + queryset = ServiceTemplate.objects.all() + + +class ServiceTemplateBulkImportView(generic.BulkImportView): + queryset = ServiceTemplate.objects.all() + model_form = forms.ServiceTemplateCSVForm + table = tables.ServiceTemplateTable + + +class ServiceTemplateBulkEditView(generic.BulkEditView): + queryset = ServiceTemplate.objects.all() + filterset = filtersets.ServiceTemplateFilterSet + table = tables.ServiceTemplateTable + form = forms.ServiceTemplateBulkEditForm + + +class ServiceTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = ServiceTemplate.objects.all() + filterset = filtersets.ServiceTemplateFilterSet + table = tables.ServiceTemplateTable + + # # Services # @@ -1050,16 +1093,16 @@ class ServiceEditView(generic.ObjectEditView): template_name = 'ipam/service_edit.html' +class ServiceDeleteView(generic.ObjectDeleteView): + queryset = Service.objects.all() + + class ServiceBulkImportView(generic.BulkImportView): queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable -class ServiceDeleteView(generic.ObjectDeleteView): - queryset = Service.objects.all() - - class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index dc83d02f9..85d86a47a 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -264,6 +264,7 @@ IPAM_MENU = Menu( label='Other', items=( get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), + get_model_item('ipam', 'servicetemplate', 'Service Templates'), get_model_item('ipam', 'service', 'Services'), ), ), diff --git a/netbox/templates/ipam/servicetemplate.html b/netbox/templates/ipam/servicetemplate.html new file mode 100644 index 000000000..6e2aacb34 --- /dev/null +++ b/netbox/templates/ipam/servicetemplate.html @@ -0,0 +1,46 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load perms %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    Service Template
    +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Protocol{{ object.get_protocol_display }}
    Ports{{ object.port_list }}
    Description{{ object.description|placeholder }}
    +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} From b07a7ba9bc78256164439d17ce10a1f22ea3dac4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 12 Jan 2022 17:07:54 -0500 Subject: [PATCH 087/271] Fix display of custom object fields within tables --- netbox/utilities/tables/columns.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/tables/columns.py b/netbox/utilities/tables/columns.py index a75172575..24c22ec0c 100644 --- a/netbox/utilities/tables/columns.py +++ b/netbox/utilities/tables/columns.py @@ -324,7 +324,10 @@ class CustomFieldColumn(tables.Column): # Linkify custom URLs return mark_safe(f'{value}') if value is not None: - return value + obj = self.customfield.deserialize(value) + if hasattr(obj, 'get_absolute_url'): + return mark_safe(f'{obj}') + return obj return self.default From bb5ded203937bea67cca23511640587961aac769 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 10:32:42 -0500 Subject: [PATCH 088/271] Enable creating services from templates in the UI --- netbox/ipam/forms/models.py | 34 +++++++++++ netbox/ipam/urls.py | 2 +- netbox/ipam/views.py | 6 ++ netbox/templates/ipam/service_create.html | 74 +++++++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/ipam/service_create.html diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 9aabd4d54..34c67773f 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -31,6 +31,7 @@ __all__ = ( 'RoleForm', 'RouteTargetForm', 'ServiceForm', + 'ServiceCreateForm', 'ServiceTemplateForm', 'VLANForm', 'VLANGroupForm', @@ -880,3 +881,36 @@ class ServiceForm(CustomFieldModelForm): 'protocol': StaticSelect(), 'ipaddresses': StaticSelectMultiple(), } + + +class ServiceCreateForm(ServiceForm): + service_template = DynamicModelChoiceField( + queryset=ServiceTemplate.objects.all(), + required=False + ) + + class Meta(ServiceForm.Meta): + fields = [ + 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', + 'tags', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Fields which may be populated from a ServiceTemplate are not required + for field in ('name', 'protocol', 'ports'): + self.fields[field].required = False + del(self.fields[field].widget.attrs['required']) + + def clean(self): + if self.cleaned_data['service_template']: + # Create a new Service from the specified template + service_template = self.cleaned_data['service_template'] + self.cleaned_data['name'] = service_template.name + self.cleaned_data['protocol'] = service_template.protocol + self.cleaned_data['ports'] = service_template.ports + if not self.cleaned_data['description']: + self.cleaned_data['description'] = service_template.description + elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): + raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index fe8cfd150..0a4eddc6c 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -176,7 +176,7 @@ urlpatterns = [ # Services path('services/', views.ServiceListView.as_view(), name='service_list'), - path('services/add/', views.ServiceEditView.as_view(), name='service_add'), + path('services/add/', views.ServiceCreateView.as_view(), name='service_add'), path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'), path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f5aa0a7d7..35d5cf502 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1087,6 +1087,12 @@ class ServiceView(generic.ObjectView): queryset = Service.objects.prefetch_related('ipaddresses') +class ServiceCreateView(generic.ObjectEditView): + queryset = Service.objects.all() + model_form = forms.ServiceCreateForm + template_name = 'ipam/service_create.html' + + class ServiceEditView(generic.ObjectEditView): queryset = Service.objects.prefetch_related('ipaddresses') model_form = forms.ServiceForm diff --git a/netbox/templates/ipam/service_create.html b/netbox/templates/ipam/service_create.html new file mode 100644 index 000000000..022821bcf --- /dev/null +++ b/netbox/templates/ipam/service_create.html @@ -0,0 +1,74 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
    +
    +
    Service
    +
    + + {# Device/VM selection #} +
    +
    + +
    +
    +
    +
    + {% render_field form.device %} +
    +
    + {% render_field form.virtual_machine %} +
    +
    + + {# Template or custom #} +
    +
    + +
    +
    +
    +
    + {% render_field form.service_template %} +
    +
    + {% render_field form.name %} + {% render_field form.protocol %} + {% render_field form.ports %} +
    +
    + {% render_field form.ipaddresses %} + {% render_field form.description %} + {% render_field form.tags %} +
    + + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} + {% endif %} +{% endblock %} From 5b851a2d094cf2fe85fa94750900fee3b0254cf0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 10:48:08 -0500 Subject: [PATCH 089/271] Changelog for #1591 --- docs/release-notes/version-3.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index a2bc5988e..b82dce917 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,10 @@ ### New Features +#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) + +A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. + #### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. @@ -83,6 +87,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` * `/api/dcim/module-types/` + * `/api/extras/service-templates/` * circuits.ProviderNetwork * Added `service_id` field * dcim.ConsolePort From 707aad234eaca214557832fa29652fd90c635a39 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 11:27:29 -0500 Subject: [PATCH 090/271] Add view test for creating service from template --- netbox/ipam/tests/test_views.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 928a8b1c8..16439f453 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -719,3 +719,29 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'ports': '106,107', 'description': 'New description', } + + def test_create_from_template(self): + self.add_permissions('ipam.add_service') + + device = Device.objects.first() + service_template = ServiceTemplate.objects.create( + name='HTTP', + protocol=ServiceProtocolChoices.PROTOCOL_TCP, + ports=[80], + description='Hypertext transfer protocol' + ) + + request = { + 'path': self._get_url('add'), + 'data': { + 'device': device.pk, + 'service_template': service_template.pk, + }, + } + self.assertHttpStatus(self.client.post(**request), 302) + instance = self._get_queryset().order_by('pk').last() + self.assertEqual(instance.device, device) + self.assertEqual(instance.name, service_template.name) + self.assertEqual(instance.protocol, service_template.protocol) + self.assertEqual(instance.ports, service_template.ports) + self.assertEqual(instance.description, service_template.description) From b21b6238cf7392b20c348474f4e4d84510cf5651 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 11:52:06 -0500 Subject: [PATCH 091/271] Fix test permissions --- netbox/ipam/tests/test_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 16439f453..672cfbe08 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -720,6 +720,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_from_template(self): self.add_permissions('ipam.add_service') From 7767692394c25fb050193d67afa8546caffcd1e7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 13 Jan 2022 12:10:25 -0500 Subject: [PATCH 092/271] Changelog for #8295 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b82dce917..dec5843dc 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -69,6 +69,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group +* [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links ### Other Changes From 3e3880823b6f2fb528cd64c00acb863f17e96bae Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 17 Jan 2022 11:12:54 -0500 Subject: [PATCH 093/271] Merge v3.1.6 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 4 -- docs/plugins/development.md | 3 + docs/release-notes/version-3.1.md | 12 +++- netbox/circuits/api/serializers.py | 2 +- netbox/circuits/tables.py | 12 ++-- netbox/dcim/api/serializers.py | 14 +++-- netbox/dcim/forms/filtersets.py | 2 +- netbox/dcim/svg.py | 7 ++- netbox/dcim/tables/cables.py | 2 +- netbox/dcim/tables/devices.py | 31 ++++++---- netbox/dcim/tables/devicetypes.py | 4 +- netbox/dcim/tables/power.py | 4 +- netbox/dcim/tables/racks.py | 12 ++-- netbox/dcim/tables/sites.py | 12 ++-- netbox/extras/api/serializers.py | 13 +++-- netbox/extras/forms/customfields.py | 63 ++++++++++----------- netbox/extras/tables.py | 12 ++-- netbox/ipam/models/ip.py | 21 ++++++- netbox/ipam/tables/fhrp.py | 2 +- netbox/ipam/tables/ip.py | 27 ++++++--- netbox/ipam/tables/services.py | 5 +- netbox/ipam/tables/vlans.py | 7 ++- netbox/ipam/tables/vrfs.py | 5 +- netbox/netbox/views/generic/bulk_views.py | 2 +- netbox/templates/inc/filter_list.html | 6 +- netbox/templates/ipam/asn.html | 2 +- netbox/tenancy/tables.py | 17 ++++-- netbox/utilities/forms/forms.py | 14 ++--- netbox/virtualization/tables.py | 19 +++++-- netbox/wireless/tables.py | 8 ++- requirements.txt | 6 +- 33 files changed, 225 insertions(+), 129 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 594f23f9a..16182af64 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.5 + placeholder: v3.1.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index b1193ae02..0be999b16 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.5 + placeholder: v3.1.6 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index cbc893aa9..aaa9c7f44 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -98,10 +98,6 @@ psycopg2-binary # https://github.com/yaml/pyyaml PyYAML -# In-memory key/value store used for caching and queuing -# https://github.com/andymccurdy/redis-py -redis - # Social authentication framework # https://github.com/python-social-auth/social-core social-auth-core[all] diff --git a/docs/plugins/development.md b/docs/plugins/development.md index d20f73cb6..d488cad6b 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -1,5 +1,8 @@ # Plugin Development +!!! info "Help Improve the NetBox Plugins Framework!" + We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338). + This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. Plugins can do a lot, including: diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 898d77437..c42837b24 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,16 +1,23 @@ # NetBox v3.1 -## v3.1.6 (FUTURE) +## v3.1.7 (FUTURE) + +--- + +## v3.1.6 (2022-01-17) ### Enhancements * [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table * [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats * [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types +* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation * [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables +* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times ### Bug Fixes +* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations * [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer * [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form * [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views @@ -19,6 +26,9 @@ * [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously * [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values * [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter +* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers +* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form +* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms --- diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 7a827d547..90767d081 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - '_occupied', + '_occupied', 'created', 'last_updated', ] diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 69fe3cf1f..b5fdc5440 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -66,7 +66,7 @@ class ProviderTable(BaseTable): model = Provider fields = ( 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', - 'comments', 'tags', + 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') @@ -90,7 +90,9 @@ class ProviderNetworkTable(BaseTable): class Meta(BaseTable.Meta): model = ProviderNetwork - fields = ('pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'tags') + fields = ( + 'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags', + ) default_columns = ('pk', 'name', 'provider', 'service_id', 'description') @@ -112,7 +114,9 @@ class CircuitTypeTable(BaseTable): class Meta(BaseTable.Meta): model = CircuitType - fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') @@ -149,7 +153,7 @@ class CircuitTable(BaseTable): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'tags', + 'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4d8638231..527c1e948 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -221,7 +221,7 @@ class RackReservationSerializer(PrimaryModelSerializer): class Meta: model = RackReservation fields = [ - 'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', + 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags', 'custom_fields', ] @@ -913,7 +913,7 @@ class CableSerializer(PrimaryModelSerializer): fields = [ 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', 'custom_fields', + 'tags', 'custom_fields', 'created', 'last_updated', ] def _get_termination(self, obj, side): @@ -1007,7 +1007,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer): class Meta: model = VirtualChassis - fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] + fields = [ + 'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count', + 'created', 'last_updated', + ] # @@ -1026,7 +1029,10 @@ class PowerPanelSerializer(PrimaryModelSerializer): class Meta: model = PowerPanel - fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] + fields = [ + 'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count', + 'created', 'last_updated', + ] class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index f84b42aa1..6c192f462 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -157,7 +157,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Location field_groups = [ - ['q'], + ['q', 'tag'], ['region_id', 'site_group_id', 'site_id', 'parent_id'], ['tenant_group_id', 'tenant_id'], ] diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index e19e8fa2f..1058d8385 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -19,7 +19,12 @@ __all__ = ( def get_device_name(device): - return device.name or str(device.device_type) + if device.virtual_chassis: + return f'{device.virtual_chassis.name}:{device.vc_position}' + elif device.name: + return device.name + else: + return str(device.device_type) class RackElevationSVG: diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 9b912894b..bea2c0adf 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -56,7 +56,7 @@ class CableTable(BaseTable): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', 'tenant', 'color', 'length', 'tags', + 'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7bfa09c21..f5ca49187 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -99,7 +99,7 @@ class DeviceRoleTable(BaseTable): model = DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') @@ -131,7 +131,7 @@ class PlatformTable(BaseTable): model = Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'tags', 'actions', + 'description', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', @@ -205,7 +205,8 @@ class DeviceTable(BaseTable): fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', + 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -311,7 +312,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): model = ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -353,7 +354,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = ConsoleServerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -396,7 +397,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', - 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -444,7 +446,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', - 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -524,6 +527,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -594,6 +598,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -641,7 +646,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -691,7 +696,11 @@ class DeviceBayTable(DeviceComponentTable): class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags', + 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description') @@ -774,7 +783,7 @@ class InventoryItemTable(DeviceComponentTable): model = InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', - 'asset_tag', 'description', 'discovered', 'tags', + 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', @@ -847,5 +856,5 @@ class VirtualChassisTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualChassis - fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags') + fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index ecec67f7d..93832d706 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -53,7 +53,7 @@ class ManufacturerTable(BaseTable): model = Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', @@ -87,7 +87,7 @@ class DeviceTypeTable(BaseTable): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'comments', 'instance_count', 'tags', + 'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ac58b64de..c1ea8a34c 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPanel - fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags') + fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -72,7 +72,7 @@ class PowerFeedTable(CableTerminationTable): fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', - 'comments', 'tags', + 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 565966a39..55c6f9ba8 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -30,7 +30,10 @@ class RackRoleTable(BaseTable): class Meta(BaseTable.Meta): model = RackRole - fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'rack_count', 'color', 'description') @@ -86,8 +89,9 @@ class RackTable(BaseTable): class Meta(BaseTable.Meta): model = Rack fields = ( - 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', - 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', + 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', + 'get_power_utilization', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', @@ -125,6 +129,6 @@ class RackReservationTable(BaseTable): model = RackReservation fields = ( 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 98c5e3fd3..32bf000ef 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -35,7 +35,9 @@ class RegionTable(BaseTable): class Meta(BaseTable.Meta): model = Region - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -59,7 +61,9 @@ class SiteGroupTable(BaseTable): class Meta(BaseTable.Meta): model = SiteGroup - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -96,7 +100,7 @@ class SiteTable(BaseTable): fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', - 'actions', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') @@ -135,6 +139,6 @@ class LocationTable(BaseTable): model = Location fields = ( 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', - 'actions', + 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6279ea2b7..79fab4a90 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -63,7 +63,7 @@ class WebhookSerializer(ValidatedModelSerializer): fields = [ 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - 'conditions', 'ssl_verification', 'ca_file_path', + 'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated', ] @@ -84,7 +84,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): model = CustomField fields = [ 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', - 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', + 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', + 'last_updated', ] @@ -102,7 +103,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): model = CustomLink fields = [ 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', - 'button_class', 'new_window', + 'button_class', 'new_window', 'created', 'last_updated', ] @@ -120,7 +121,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer): model = ExportTemplate fields = [ 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', + 'file_extension', 'as_attachment', 'created', 'last_updated', ] @@ -134,7 +135,9 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag - fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated', + ] # diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 0a2299945..8912d0365 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -4,7 +4,7 @@ from django.db.models import Q from extras.choices import * from extras.models import * -from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm +from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm __all__ = ( 'CustomFieldModelCSVForm', @@ -34,6 +34,9 @@ class CustomFieldsMixin: raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") return ContentType.objects.get_for_model(self.model) + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type) + def _get_form_field(self, customfield): return customfield.to_form_field() @@ -41,10 +44,7 @@ class CustomFieldsMixin: """ Append form fields for all CustomFields assigned to this object type. """ - content_type = self._get_content_type() - - # Append form fields; assign initial values if modifying and existing object - for customfield in CustomField.objects.filter(content_types=content_type): + for customfield in self._get_custom_fields(self._get_content_type()): field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) @@ -89,40 +89,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): return customfield.to_form_field(for_csv_import=True) -class CustomFieldModelBulkEditForm(BulkEditForm): +class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) - self.custom_fields = [] - self.obj_type = ContentType.objects.get_for_model(self.model) - - # Add all applicable CustomFields to the form - custom_fields = CustomField.objects.filter(content_types=self.obj_type) - for cf in custom_fields: + def _append_customfield_fields(self): + """ + Append form fields for all CustomFields assigned to this object type. + """ + for customfield in self._get_custom_fields(self._get_content_type()): # Annotate non-required custom fields as nullable - if not cf.required: - self.nullable_fields.append(cf.name) - self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False) - # Annotate this as a custom field - self.custom_fields.append(cf.name) + if not customfield.required: + self.nullable_fields.append(customfield.name) + + self.fields[customfield.name] = self._get_form_field(customfield) + + # Annotate the field in the list of CustomField form fields + self.custom_fields[customfield.name] = customfield -class CustomFieldModelFilterForm(FilterForm): +class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form): + q = forms.CharField( + required=False, + label='Search' + ) - def __init__(self, *args, **kwargs): - - self.obj_type = ContentType.objects.get_for_model(self.model) - - super().__init__(*args, **kwargs) - - # Add all applicable CustomFields to the form - self.custom_field_filters = [] - custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(type=CustomFieldTypeChoices.TYPE_JSON) ) - for cf in custom_fields: - field_name = f'cf_{cf.name}' - self.fields[field_name] = cf.to_form_field(set_initial=False, enforce_required=False) - self.custom_field_filters.append(field_name) + + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index adfccb575..7d60518b2 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -58,7 +58,7 @@ class CustomFieldTable(BaseTable): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', + 'description', 'filter_logic', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description') @@ -80,7 +80,7 @@ class CustomLinkTable(BaseTable): model = CustomLink fields = ( 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', - 'button_class', 'new_window', + 'button_class', 'new_window', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') @@ -101,6 +101,7 @@ class ExportTemplateTable(BaseTable): model = ExportTemplate fields = ( 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', @@ -135,7 +136,7 @@ class WebhookTable(BaseTable): model = Webhook fields = ( 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', - 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', + 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', @@ -156,7 +157,7 @@ class TagTable(BaseTable): class Meta(BaseTable.Meta): model = Tag - fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions') + fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') @@ -193,7 +194,8 @@ class ConfigContextTable(BaseTable): model = ConfigContext fields = ( 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 13ae0f54f..9d6fb5edc 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -125,11 +125,30 @@ class ASN(PrimaryModel): verbose_name_plural = 'ASNs' def __str__(self): - return f'AS{self.asn}' + return f'AS{self.asn_with_asdot}' def get_absolute_url(self): return reverse('ipam:asn', args=[self.pk]) + @property + def asn_asdot(self): + """ + Return ASDOT notation for AS numbers greater than 16 bits. + """ + if self.asn > 65535: + return f'{self.asn // 65536}.{self.asn % 65536}' + return self.asn + + @property + def asn_with_asdot(self): + """ + Return both plain and ASDOT notation, where applicable. + """ + if self.asn > 65535: + return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})' + else: + return self.asn + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index a691b945b..f9119126c 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -38,7 +38,7 @@ class FHRPGroupTable(BaseTable): model = FHRPGroup fields = ( 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 9914fb22b..b2e4ef958 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -91,7 +91,10 @@ class RIRTable(BaseTable): class Meta(BaseTable.Meta): model = RIR - fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'created', + 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description') @@ -102,8 +105,10 @@ class RIRTable(BaseTable): class ASNTable(BaseTable): pk = ToggleColumn() asn = tables.Column( + accessor=tables.A('asn_asdot'), linkify=True ) + site_count = LinkedCountColumn( viewname='dcim:site_list', url_params={'asn_id': 'pk'}, @@ -112,7 +117,7 @@ class ASNTable(BaseTable): class Meta(BaseTable.Meta): model = ASN - fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') + fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant') @@ -144,7 +149,10 @@ class AggregateTable(BaseTable): class Meta(BaseTable.Meta): model = Aggregate - fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags') + fields = ( + 'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') @@ -173,7 +181,10 @@ class RoleTable(BaseTable): class Meta(BaseTable.Meta): model = Role - fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'created', + 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description') @@ -260,8 +271,8 @@ class PrefixTable(BaseTable): class Meta(BaseTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group', - 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', + 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -302,7 +313,7 @@ class IPRangeTable(BaseTable): model = IPRange fields = ( 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', - 'utilization', 'tags', + 'utilization', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -360,7 +371,7 @@ class IPAddressTable(BaseTable): model = IPAddress fields = ( 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 783cb3537..5c3e14b2c 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -45,5 +45,8 @@ class ServiceTable(BaseTable): class Meta(BaseTable.Meta): model = Service - fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 3454ddff4..d387e24dd 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -85,7 +85,7 @@ class VLANGroupTable(BaseTable): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', - 'tags', 'actions', + 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') @@ -127,7 +127,10 @@ class VLANTable(BaseTable): class Meta(BaseTable.Meta): model = VLAN - fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags') + fields = ( + 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, VLAN) else '', diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 1264368f4..e71fb1fa4 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -47,7 +47,8 @@ class VRFTable(BaseTable): class Meta(BaseTable.Meta): model = VRF fields = ( - 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags', + 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'rd', 'tenant', 'description') @@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable): class Meta(BaseTable.Meta): model = RouteTarget - fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags') + fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index d76bc598d..e9b213a95 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -287,7 +287,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def _update_objects(self, form, request): custom_fields = getattr(form, 'custom_fields', []) standard_fields = [ - field for field in form.fields if field not in custom_fields + ['pk'] + field for field in form.fields if field not in list(custom_fields) + ['pk'] ] nullified_fields = request.POST.getlist('_nullify') updated_objects = [] diff --git a/netbox/templates/inc/filter_list.html b/netbox/templates/inc/filter_list.html index 1e73fedb2..e6a1e6a28 100644 --- a/netbox/templates/inc/filter_list.html +++ b/netbox/templates/inc/filter_list.html @@ -24,17 +24,17 @@ {% else %} {# List all non-customfield filters as declared in the form class #} {% for field in filter_form.visible_fields %} - {% if not filter_form.custom_field_filters or field.name not in filter_form.custom_field_filters %} + {% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %}
    {% render_field field %}
    {% endif %} {% endfor %} {% endif %} - {% if filter_form.custom_field_filters %} + {% if filter_form.custom_fields %} {# List all custom field filters #}
    - {% for name in filter_form.custom_field_filters %} + {% for name in filter_form.custom_fields %}
    {% with field=filter_form|get_item:name %} {% render_field field %} diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 53afd5ebb..4a1ecda0d 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -18,7 +18,7 @@ - + diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index f15e67eab..55a0591b5 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -62,7 +62,9 @@ class TenantGroupTable(BaseTable): class Meta(BaseTable.Meta): model = TenantGroup - fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'tenant_count', 'description') @@ -81,7 +83,7 @@ class TenantTable(BaseTable): class Meta(BaseTable.Meta): model = Tenant - fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags') + fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'group', 'description') @@ -105,7 +107,9 @@ class ContactGroupTable(BaseTable): class Meta(BaseTable.Meta): model = ContactGroup - fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'contact_count', 'description') @@ -117,7 +121,7 @@ class ContactRoleTable(BaseTable): class Meta(BaseTable.Meta): model = ContactRole - fields = ('pk', 'name', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'description', 'slug', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'description') @@ -142,7 +146,10 @@ class ContactTable(BaseTable): class Meta(BaseTable.Meta): model = Contact - fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags') + fields = ( + 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 87fa4ae33..88f837b2b 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -3,7 +3,6 @@ import re import yaml from django import forms -from django.utils.translation import gettext as _ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect @@ -11,6 +10,7 @@ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSel __all__ = ( 'BootstrapMixin', 'BulkEditForm', + 'BulkEditBaseForm', 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', @@ -75,11 +75,10 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) -class BulkEditForm(BootstrapMixin, forms.Form): +class BulkEditBaseForm(forms.Form): """ Base form for editing multiple objects in bulk """ - def __init__(self, model, *args, **kwargs): super().__init__(*args, **kwargs) self.model = model @@ -90,6 +89,10 @@ class BulkEditForm(BootstrapMixin, forms.Form): self.nullable_fields = self.Meta.nullable_fields +class BulkEditForm(BootstrapMixin, BulkEditBaseForm): + pass + + class BulkRenameForm(BootstrapMixin, forms.Form): """ An extendable form to be used for renaming objects in bulk. @@ -185,10 +188,7 @@ class FilterForm(BootstrapMixin, forms.Form): """ q = forms.CharField( required=False, - widget=forms.TextInput( - attrs={'placeholder': _('All fields')} - ), - label=_('Search') + label='Search' ) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 1920c1140..517f0a4b8 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -44,7 +44,9 @@ class ClusterTypeTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterType - fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'created', 'last_updated', 'tags', 'actions', + ) default_columns = ('pk', 'name', 'cluster_count', 'description') @@ -66,7 +68,9 @@ class ClusterGroupTable(BaseTable): class Meta(BaseTable.Meta): model = ClusterGroup - fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'cluster_count', 'description') @@ -108,7 +112,10 @@ class ClusterTable(BaseTable): class Meta(BaseTable.Meta): model = Cluster - fields = ('pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags') + fields = ( + 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') @@ -149,8 +156,8 @@ class VirtualMachineTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', - 'primary_ip6', 'primary_ip', 'comments', 'tags', + 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', @@ -177,7 +184,7 @@ class VMInterfaceTable(BaseInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 67d46f248..650d91554 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -27,7 +27,9 @@ class WirelessLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLANGroup - fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions') + fields = ( + 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) default_columns = ('pk', 'name', 'wirelesslan_count', 'description') @@ -50,7 +52,7 @@ class WirelessLANTable(BaseTable): model = WirelessLAN fields = ( 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') @@ -99,7 +101,7 @@ class WirelessLinkTable(BaseTable): model = WirelessLink fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', - 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', diff --git a/requirements.txt b/requirements.txt index e0148e74e..83c1b9f2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Django==3.2.11 -django-cors-headers==3.10.1 +django-cors-headers==3.11.0 django-debug-toolbar==3.2.4 django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 @@ -10,7 +10,7 @@ django-redis==5.2.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.0.0 -django-timezone-field==4.2.1 +django-timezone-field==4.2.3 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 graphene_django==2.15.0 @@ -18,7 +18,7 @@ gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==8.1.4 +mkdocs-material==8.1.7 netaddr==0.8.0 Pillow==8.4.0 psycopg2-binary==2.9.3 From 3fcae36cf15c141a59001b7057933e0bbe9b428f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 18 Jan 2022 16:57:54 -0500 Subject: [PATCH 094/271] Closes #8307: Add data_type indicator to REST API serializer for custom fields --- docs/release-notes/version-3.2.md | 1 + netbox/extras/api/serializers.py | 19 ++++- netbox/extras/tests/test_customfields.py | 95 +++++++++++++++++++----- 3 files changed, 93 insertions(+), 22 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index dec5843dc..09701f800 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -71,6 +71,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group * [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links +* [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields ### Other Changes diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 79fab4a90..36b307b39 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -79,15 +79,28 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) type = ChoiceField(choices=CustomFieldTypeChoices) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) + data_type = serializers.SerializerMethodField() class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', - 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', - 'last_updated', + 'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required', + 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choices', 'created', 'last_updated', ] + def get_data_type(self, obj): + types = CustomFieldTypeChoices + if obj.type == types.TYPE_INTEGER: + return 'integer' + if obj.type == types.TYPE_BOOLEAN: + return 'boolean' + if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): + return 'object' + if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT): + return 'array' + return 'string' + # # Custom links diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 1a1fc13a8..3a5fe3ac9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -378,9 +378,22 @@ class CustomFieldAPITest(APITestCase): CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'), - CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=( - 'Foo', 'Bar', 'Baz' - )), + CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT, + name='select_field', + default='Foo', + choices=( + 'Foo', 'Bar', 'Baz' + ) + ), + CustomField( + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + name='multiselect_field', + default=['Foo'], + choices=( + 'Foo', 'Bar', 'Baz' + ) + ), CustomField( type=CustomFieldTypeChoices.TYPE_OBJECT, name='object_field', @@ -416,11 +429,37 @@ class CustomFieldAPITest(APITestCase): custom_fields[5].name: 'http://example.com/2', custom_fields[6].name: '{"foo": 1, "bar": 2}', custom_fields[7].name: 'Bar', - custom_fields[8].name: vlans[1].pk, - custom_fields[9].name: [vlans[2].pk, vlans[3].pk], + custom_fields[8].name: ['Bar', 'Baz'], + custom_fields[9].name: vlans[1].pk, + custom_fields[10].name: [vlans[2].pk, vlans[3].pk], } sites[1].save() + def test_get_custom_fields(self): + TYPES = { + CustomFieldTypeChoices.TYPE_TEXT: 'string', + CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', + CustomFieldTypeChoices.TYPE_INTEGER: 'integer', + CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', + CustomFieldTypeChoices.TYPE_DATE: 'string', + CustomFieldTypeChoices.TYPE_URL: 'string', + CustomFieldTypeChoices.TYPE_JSON: 'object', + CustomFieldTypeChoices.TYPE_SELECT: 'string', + CustomFieldTypeChoices.TYPE_MULTISELECT: 'array', + CustomFieldTypeChoices.TYPE_OBJECT: 'object', + CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array', + } + + self.add_permissions('extras.view_customfield') + url = reverse('extras-api:customfield-list') + response = self.client.get(url, **self.header) + self.assertEqual(response.data['count'], len(TYPES)) + + # Validate data types + for customfield in response.data['results']: + cf_type = customfield['type']['value'] + self.assertEqual(customfield['data_type'], TYPES[cf_type]) + def test_get_single_object_without_custom_field_data(self): """ Validate that custom fields are present on an object even if it has no values defined. @@ -439,7 +478,8 @@ class CustomFieldAPITest(APITestCase): 'date_field': None, 'url_field': None, 'json_field': None, - 'choice_field': None, + 'select_field': None, + 'multiselect_field': None, 'object_field': None, 'multiobject_field': None, }) @@ -462,7 +502,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) - self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) + self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field']) + self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field']) self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], @@ -495,7 +536,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) - self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) + self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], @@ -511,7 +553,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) @@ -530,7 +573,8 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'choice_field': 'Bar', + 'select_field': 'Bar', + 'multiselect_field': ['Bar', 'Baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), }, @@ -551,7 +595,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) - self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) + self.assertEqual(response_cf['select_field'], data_cf['select_field']) + self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field']) self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -567,7 +612,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field']) self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field']) @@ -611,7 +657,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) - self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field']) + self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) + self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -627,7 +674,8 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field']) self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) @@ -643,7 +691,8 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'choice_field': 'Bar', + 'select_field': 'Bar', + 'multiselect_field': ['Bar', 'Baz'], 'object_field': VLAN.objects.get(vid=2).pk, 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), } @@ -682,7 +731,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) - self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field']) + self.assertEqual(response_cf['select_field'], custom_field_data['select_field']) + self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], custom_field_data['multiobject_field'] @@ -697,7 +748,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field']) + self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field']) self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field']) def test_update_single_object_with_values(self): @@ -728,7 +781,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) - self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) + self.assertEqual(response_cf['select_field'], original_cfvs['select_field']) + self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], original_cfvs['multiobject_field'] @@ -743,7 +798,9 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field']) + self.assertEqual(site2.custom_field_data['select_field'], original_cfvs['select_field']) + self.assertEqual(site2.custom_field_data['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field']) self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) def test_minimum_maximum_values_validation(self): From bf6345aa90afd5ee5c7a59ae6fd1d4ad73509803 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 09:14:38 -0500 Subject: [PATCH 095/271] Closes #5429: Enable toggling the placement of table paginators --- docs/development/user-preferences.md | 1 + docs/release-notes/version-3.2.md | 1 + netbox/netbox/preferences.py | 10 ++++++++++ netbox/templates/htmx/table.html | 12 ++++++++++-- netbox/users/forms.py | 1 + 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index 622fbb4b9..ceb5321a9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -8,6 +8,7 @@ The `users.UserConfig` model holds individual preferences for each user in the f |--------------------------|---------------------------------------------------------------| | data_format | Preferred format when rendering raw data (JSON or YAML) | | pagination.per_page | The number of items to display per page of a paginated table | +| pagination.placement | Where to display the paginator controls relative to the table | | tables.${table}.columns | The ordered list of columns to display when viewing the table | | tables.${table}.ordering | A list of column names by which the table should be ordered | | ui.colormode | Light or dark mode in the user interface | diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 09701f800..432d6fafc 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -60,6 +60,7 @@ Inventory item templates can be arranged hierarchically within a device type, an ### Enhancements +* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators * [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 4cad8cf24..aec8bc752 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -26,6 +26,16 @@ PREFERENCES = { description='The number of objects to display per page', coerce=lambda x: int(x) ), + 'pagination.placement': UserPreference( + label='Paginator placement', + choices=( + ('bottom', 'Bottom'), + ('top', 'Top'), + ('both', 'Both'), + ), + description='Where the paginator controls will be displayed relative to a table', + default='bottom' + ), # Miscellaneous 'data_format': UserPreference( diff --git a/netbox/templates/htmx/table.html b/netbox/templates/htmx/table.html index c5d0ac46b..6f168ac52 100644 --- a/netbox/templates/htmx/table.html +++ b/netbox/templates/htmx/table.html @@ -1,5 +1,13 @@ {# Render an HTMX-enabled table with paginator #} +{% load helpers %} {% load render_table from django_tables2 %} -{% render_table table 'inc/table_htmx.html' %} -{% include 'inc/paginator_htmx.html' with paginator=table.paginator page=table.page %} +{% with preferences|get_key:"pagination.placement" as paginator_placement %} + {% if paginator_placement == 'top' or paginator_placement == 'both' %} + {% include 'inc/paginator_htmx.html' with paginator=table.paginator page=table.page %} + {% endif %} + {% render_table table 'inc/table_htmx.html' %} + {% if paginator_placement != 'top' %} + {% include 'inc/paginator_htmx.html' with paginator=table.paginator page=table.page %} + {% endif %} +{% endwith %} diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 70e300a8c..5a99adc5a 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -47,6 +47,7 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe fieldsets = ( ('User Interface', ( 'pagination.per_page', + 'pagination.placement', 'ui.colormode', )), ('Miscellaneous', ( From c7825e391cabbdcc7616e78d70313957fee38b25 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 14:46:50 -0500 Subject: [PATCH 096/271] Designate feature mixin classes & employ class_prepared signal to register features --- netbox/extras/utils.py | 23 ++- netbox/netbox/models/__init__.py | 129 +++++++++++++ .../netbox/{models.py => models/features.py} | 171 ++++++------------ 3 files changed, 198 insertions(+), 125 deletions(-) create mode 100644 netbox/netbox/models/__init__.py rename netbox/netbox/{models.py => models/features.py} (57%) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index ace49cce5..16749ad5d 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -57,21 +57,24 @@ class FeatureQuery: return query +def register_features(model, features): + if 'model_features' not in registry: + registry['model_features'] = { + f: collections.defaultdict(list) for f in EXTRAS_FEATURES + } + for feature in features: + if feature not in EXTRAS_FEATURES: + raise ValueError(f"{feature} is not a valid extras feature!") + app_label, model_name = model._meta.label_lower.split('.') + registry['model_features'][feature][app_label].append(model_name) + + def extras_features(*features): """ Decorator used to register extras provided features to a model """ def wrapper(model_class): # Initialize the model_features store if not already defined - if 'model_features' not in registry: - registry['model_features'] = { - f: collections.defaultdict(list) for f in EXTRAS_FEATURES - } - for feature in features: - if feature in EXTRAS_FEATURES: - app_label, model_name = model_class._meta.label_lower.split('.') - registry['model_features'][feature][app_label].append(model_name) - else: - raise ValueError('{} is not a valid extras feature!'.format(feature)) + register_features(model_class, features) return model_class return wrapper diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py new file mode 100644 index 000000000..e715cf329 --- /dev/null +++ b/netbox/netbox/models/__init__.py @@ -0,0 +1,129 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.core.validators import ValidationError +from django.db import models +from mptt.models import MPTTModel, TreeForeignKey + +from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet +from netbox.models.features import * + +__all__ = ( + 'BigIDModel', + 'ChangeLoggedModel', + 'NestedGroupModel', + 'OrganizationalModel', + 'PrimaryModel', +) + + +# +# Base model classes +# + +class BigIDModel(models.Model): + """ + Abstract base model for all data objects. Ensures the use of a 64-bit PK. + """ + id = models.BigAutoField( + primary_key=True + ) + + class Meta: + abstract = True + + +class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): + """ + Base model for all objects which support change logging. + """ + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): + """ + Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest + recursively using MPTT. Within each parent, each child instance must have a unique name. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = TreeManager() + + class Meta: + abstract = True + + class MPTTMeta: + order_insertion_by = ('name',) + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # An MPTT model cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({ + "parent": "Cannot assign self as parent." + }) + + +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): + """ + Organizational models are those which are used solely to categorize and qualify other objects, and do not convey + any real information about the infrastructure being modeled (for example, functional device roles). Organizational + models provide the following standard attributes: + - Unique name + - Unique slug (automatically derived from name) + - Optional description + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + ordering = ('name',) diff --git a/netbox/netbox/models.py b/netbox/netbox/models/features.py similarity index 57% rename from netbox/netbox/models.py rename to netbox/netbox/models/features.py index 3e6ebd8b2..99f8c00b7 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models/features.py @@ -1,34 +1,37 @@ import logging -from django.contrib.contenttypes.fields import GenericRelation +from django.db.models.signals import class_prepared +from django.dispatch import receiver + from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models -from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from extras.choices import ObjectChangeActionChoices +from extras.utils import register_features from netbox.signals import post_clean -from utilities.mptt import TreeManager -from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object __all__ = ( - 'BigIDModel', - 'ChangeLoggedModel', - 'NestedGroupModel', - 'OrganizationalModel', - 'PrimaryModel', + 'ChangeLoggingMixin', + 'CustomFieldsMixin', + 'CustomLinksMixin', + 'CustomValidationMixin', + 'ExportTemplatesMixin', + 'JobResultsMixin', + 'TagsMixin', + 'WebhooksMixin', ) # -# Mixins +# Feature mixins # class ChangeLoggingMixin(models.Model): """ - Provides change logging support. + Provides change logging support for a model. Adds the `created` and `last_updated` fields. """ created = models.DateField( auto_now_add=True, @@ -74,7 +77,7 @@ class ChangeLoggingMixin(models.Model): class CustomFieldsMixin(models.Model): """ - Provides support for custom fields. + Enables support for custom fields. """ custom_field_data = models.JSONField( encoder=DjangoJSONEncoder, @@ -128,6 +131,14 @@ class CustomFieldsMixin(models.Model): raise ValidationError(f"Missing required custom field '{cf.name}'.") +class CustomLinksMixin(models.Model): + """ + Enables support for custom links. + """ + class Meta: + abstract = True + + class CustomValidationMixin(models.Model): """ Enables user-configured validation rules for built-in models by extending the clean() method. @@ -142,9 +153,25 @@ class CustomValidationMixin(models.Model): post_clean.send(sender=self.__class__, instance=self) +class ExportTemplatesMixin(models.Model): + """ + Enables support for export templates. + """ + class Meta: + abstract = True + + +class JobResultsMixin(models.Model): + """ + Enable the assignment of JobResults to a model. + """ + class Meta: + abstract = True + + class TagsMixin(models.Model): """ - Enable the assignment of Tags. + Enable the assignment of Tags to a model. """ tags = TaggableManager( through='extras.TaggedItem' @@ -154,113 +181,27 @@ class TagsMixin(models.Model): abstract = True -# -# Base model classes - -class BigIDModel(models.Model): +class WebhooksMixin(models.Model): """ - Abstract base model for all data objects. Ensures the use of a 64-bit PK. + Enables support for webhooks. """ - id = models.BigAutoField( - primary_key=True - ) - class Meta: abstract = True -class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): - """ - Base model for all objects which support change logging. - """ - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True +FEATURES_MAP = ( + ('custom_fields', CustomFieldsMixin), + ('custom_links', CustomLinksMixin), + ('export_templates', ExportTemplatesMixin), + ('job_results', JobResultsMixin), + ('tags', TagsMixin), + ('webhooks', WebhooksMixin), +) -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Primary models represent real objects within the infrastructure being modeled. - """ - journal_entries = GenericRelation( - to='extras.JournalEntry', - object_id_field='assigned_object_id', - content_type_field='assigned_object_type' - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - - -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): - """ - Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest - recursively using MPTT. Within each parent, each child instance must have a unique name. - """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = TreeManager() - - class Meta: - abstract = True - - class MPTTMeta: - order_insertion_by = ('name',) - - def __str__(self): - return self.name - - def clean(self): - super().clean() - - # An MPTT model cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({ - "parent": "Cannot assign self as parent." - }) - - -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Organizational models are those which are used solely to categorize and qualify other objects, and do not convey - any real information about the infrastructure being modeled (for example, functional device roles). Organizational - models provide the following standard attributes: - - Unique name - - Unique slug (automatically derived from name) - - Optional description - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - ordering = ('name',) +@receiver(class_prepared) +def _register_features(sender, **kwargs): + features = { + feature for feature, cls in FEATURES_MAP if issubclass(sender, cls) + } + register_features(sender, features) From cdae0c2bef3f5cfe81acf0bf3927ed5d98a3650d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 15:16:10 -0500 Subject: [PATCH 097/271] Remove extras_features() decorator --- netbox/circuits/models/circuits.py | 7 ++---- netbox/circuits/models/providers.py | 3 --- netbox/dcim/models/cables.py | 2 -- .../dcim/models/device_component_templates.py | 16 +++--------- netbox/dcim/models/device_components.py | 12 --------- netbox/dcim/models/devices.py | 9 ------- netbox/dcim/models/power.py | 3 --- netbox/dcim/models/racks.py | 4 --- netbox/dcim/models/sites.py | 6 ----- netbox/extras/constants.py | 1 + netbox/extras/models/configcontexts.py | 5 ++-- netbox/extras/models/customfields.py | 6 ++--- netbox/extras/models/models.py | 24 +++++++----------- netbox/extras/models/tags.py | 5 ++-- netbox/extras/utils.py | 11 -------- netbox/ipam/models/fhrp.py | 6 ++--- netbox/ipam/models/ip.py | 8 ------ netbox/ipam/models/services.py | 3 --- netbox/ipam/models/vlans.py | 3 --- netbox/ipam/models/vrfs.py | 3 --- netbox/netbox/models/__init__.py | 25 +++++++++++-------- netbox/netbox/models/features.py | 17 +++++++++++++ netbox/tenancy/models/contacts.py | 8 ++---- netbox/tenancy/models/tenants.py | 3 --- netbox/virtualization/models.py | 7 ------ netbox/wireless/models.py | 4 --- 26 files changed, 58 insertions(+), 143 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 013aef557..e697caa0a 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,8 +5,8 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models.features import WebhooksMixin __all__ = ( 'Circuit', @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -44,7 +43,6 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple @@ -138,8 +136,7 @@ class Circuit(PrimaryModel): return CircuitStatusChoices.colors.get(self.status, 'secondary') -@extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, LinkTermination): +class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 153e241a7..8fd52c587 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from extras.utils import extras_features from netbox.models import PrimaryModel __all__ = ( @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -72,7 +70,6 @@ class Provider(PrimaryModel): return reverse('circuits:provider', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ProviderNetwork(PrimaryModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 12fe91036..18bf65895 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,7 +11,6 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField from utilities.utils import to_meters @@ -29,7 +28,6 @@ __all__ = ( # Cables # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Cable(PrimaryModel): """ A physical connection between two endpoints. diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b3ede8282..72ac9df40 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -7,8 +7,8 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -32,7 +32,7 @@ __all__ = ( ) -class ComponentTemplateModel(ChangeLoggedModel): +class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -135,7 +135,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel): return self.name -@extras_features('webhooks') class ConsolePortTemplate(ModularComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -164,7 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ConsoleServerPortTemplate(ModularComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -193,7 +191,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class PowerPortTemplate(ModularComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -245,7 +242,6 @@ class PowerPortTemplate(ModularComponentTemplateModel): }) -@extras_features('webhooks') class PowerOutletTemplate(ModularComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -307,7 +303,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class InterfaceTemplate(ModularComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -347,7 +342,6 @@ class InterfaceTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -420,7 +414,6 @@ class FrontPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class RearPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -460,7 +453,6 @@ class RearPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ModuleBayTemplate(ComponentTemplateModel): """ A template for a ModuleBay to be created for a new parent Device. @@ -486,7 +478,6 @@ class ModuleBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. @@ -511,7 +502,6 @@ class DeviceBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): """ A template for an InventoryItem to be created for a new parent Device. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 916161ced..c26b32575 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,7 +11,6 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG -from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -254,7 +253,6 @@ class PathEndpoint(models.Model): # Console components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -282,7 +280,6 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -314,7 +311,6 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): # Power components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -407,7 +403,6 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): } -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -522,7 +517,6 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. @@ -793,7 +787,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo # Pass-through ports # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. @@ -847,7 +840,6 @@ class FrontPort(ModularComponentModel, LinkTermination): }) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. @@ -891,7 +883,6 @@ class RearPort(ModularComponentModel, LinkTermination): # Bays # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ModuleBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -912,7 +903,6 @@ class ModuleBay(ComponentModel): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -963,7 +953,6 @@ class DeviceBay(ComponentModel): # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItemRole(OrganizationalModel): """ Inventory items may optionally be assigned a functional role. @@ -994,7 +983,6 @@ class InventoryItemRole(OrganizationalModel): return reverse('dcim:inventoryitemrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 631f0c8c1..f94c9757d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -13,7 +13,6 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices @@ -37,7 +36,6 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -70,7 +68,6 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceType(PrimaryModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -353,7 +350,6 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ModuleType(PrimaryModel): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional @@ -487,7 +483,6 @@ class ModuleType(PrimaryModel): # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -525,7 +520,6 @@ class DeviceRole(OrganizationalModel): return reverse('dcim:devicerole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". @@ -575,7 +569,6 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -1012,7 +1005,6 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Module(PrimaryModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components @@ -1095,7 +1087,6 @@ class Module(PrimaryModel, ConfigContextModel): # Virtual chassis # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index e3146c167..fe7f69df9 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,7 +6,6 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import PrimaryModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -21,7 +20,6 @@ __all__ = ( # Power # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. @@ -68,7 +66,6 @@ class PowerPanel(PrimaryModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c324d4cba..1ebbbcba4 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -13,7 +13,6 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from extras.utils import extras_features from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices @@ -34,7 +33,6 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. @@ -65,7 +63,6 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Rack(PrimaryModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. @@ -438,7 +435,6 @@ class Rack(PrimaryModel): return int(allocated_draw_total / available_power_total * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a71206224..3756933ac 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,8 +7,6 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from dcim.fields import ASNField -from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField @@ -24,7 +22,6 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ A region represents a geographic collection of sites. For example, you might create regions representing countries, @@ -111,7 +108,6 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and @@ -198,7 +194,6 @@ class SiteGroup(NestedGroupModel): # Sites # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -322,7 +317,6 @@ class Site(PrimaryModel): # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Location(NestedGroupModel): """ A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 64cc82f63..123eb0a45 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -7,6 +7,7 @@ EXTRAS_FEATURES = [ 'custom_links', 'export_templates', 'job_results', + 'journaling', 'tags', 'webhooks' ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 2a14f143f..0dc5d57db 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -5,8 +5,8 @@ from django.db import models from django.urls import reverse from extras.querysets import ConfigContextQuerySet -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.utils import deepmerge @@ -20,8 +20,7 @@ __all__ = ( # Config contexts # -@extras_features('webhooks') -class ConfigContext(ChangeLoggedModel): +class ConfigContext(WebhooksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c0f040300..923d84413 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,8 +12,9 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from extras.choices import * -from extras.utils import FeatureQuery, extras_features +from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities import filters from utilities.forms import ( CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -40,8 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -@extras_features('webhooks', 'export_templates') -class CustomField(ChangeLoggedModel): +class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ab877b99e..7189aed03 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -17,8 +17,9 @@ from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet -from extras.utils import extras_features, FeatureQuery, image_upload +from extras.utils import FeatureQuery, image_upload from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -35,8 +36,7 @@ __all__ = ( ) -@extras_features('webhooks', 'export_templates') -class Webhook(ChangeLoggedModel): +class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or delete in NetBox. The request will contain a representation of the object, which the remote application can act on. @@ -184,8 +184,7 @@ class Webhook(ChangeLoggedModel): return render_jinja2(self.payload_url, context) -@extras_features('webhooks', 'export_templates') -class CustomLink(ChangeLoggedModel): +class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -258,8 +257,7 @@ class CustomLink(ChangeLoggedModel): } -@extras_features('webhooks', 'export_templates') -class ExportTemplate(ChangeLoggedModel): +class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -345,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel): return response -@extras_features('webhooks') -class ImageAttachment(ChangeLoggedModel): +class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. """ @@ -424,8 +421,7 @@ class ImageAttachment(ChangeLoggedModel): return super().to_objectchange(action, related_object=self.parent) -@extras_features('webhooks') -class JournalEntry(ChangeLoggedModel): +class JournalEntry(WebhooksMixin, ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to preserve historical context around an object, and complements NetBox's built-in change logging. For example, you @@ -603,8 +599,7 @@ class ConfigRevision(models.Model): # Custom scripts & reports # -@extras_features('job_results') -class Script(models.Model): +class Script(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for custom scripts. Does not exist in the database. """ @@ -616,8 +611,7 @@ class Script(models.Model): # Reports # -@extras_features('job_results') -class Report(models.Model): +class Report(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for reports. Does not exist in the database. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 2925da652..df8446b9c 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -3,8 +3,8 @@ from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -13,8 +13,7 @@ from utilities.fields import ColorField # Tags # -@extras_features('webhooks', 'export_templates') -class Tag(ChangeLoggedModel, TagBase): +class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY ) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 16749ad5d..487ca3c0b 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -67,14 +67,3 @@ def register_features(model, features): raise ValueError(f"{feature} is not a valid extras feature!") app_label, model_name = model._meta.label_lower.split('.') registry['model_features'][feature][app_label].append(model_name) - - -def extras_features(*features): - """ - Decorator used to register extras provided features to a model - """ - def wrapper(model_class): - # Initialize the model_features store if not already defined - register_features(model_class, features) - return model_class - return wrapper diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 9e721c65f..a0e575e45 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class FHRPGroup(PrimaryModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) @@ -70,8 +69,7 @@ class FHRPGroup(PrimaryModel): return reverse('ipam:fhrpgroup', args=[self.pk]) -@extras_features('webhooks') -class FHRPGroupAssignment(ChangeLoggedModel): +class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): interface_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 9d6fb5edc..44dd84525 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,6 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * from ipam.constants import * @@ -54,7 +53,6 @@ class GetAvailablePrefixesMixin: return available_prefixes.iter_cidrs()[0] -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -90,7 +88,6 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ASN(PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have @@ -150,7 +147,6 @@ class ASN(PrimaryModel): return self.asn -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -253,7 +249,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or @@ -285,7 +280,6 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Prefix(GetAvailablePrefixesMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -563,7 +557,6 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class IPRange(PrimaryModel): """ A range of IP addresses, defined by start and end addresses. @@ -759,7 +752,6 @@ class IPRange(PrimaryModel): return int(float(child_count) / self.size * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 43f8353bc..bd8030a0a 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -4,7 +4,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from netbox.models import PrimaryModel @@ -47,7 +46,6 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ServiceTemplate(ServiceBase, PrimaryModel): """ A template for a Service to be applied to a device or virtual machine. @@ -64,7 +62,6 @@ class ServiceTemplate(ServiceBase, PrimaryModel): return reverse('ipam:servicetemplate', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Service(ServiceBase, PrimaryModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 31c8da2b6..f73571ea9 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -6,7 +6,6 @@ from django.db import models from django.urls import reverse from dcim.models import Interface -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet @@ -20,7 +19,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLANGroup(OrganizationalModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. @@ -118,7 +116,6 @@ class VLANGroup(OrganizationalModel): return None -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLAN(PrimaryModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index 11fab9c44..f1b2d682f 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,7 +1,6 @@ from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.constants import * from netbox.models import PrimaryModel @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VRF(PrimaryModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -75,7 +73,6 @@ class VRF(PrimaryModel): return reverse('ipam:vrf', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RouteTarget(PrimaryModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index e715cf329..e38412221 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -20,6 +19,18 @@ __all__ = ( # Base model classes # +class BaseModel( + CustomFieldsMixin, + CustomLinksMixin, + ExportTemplatesMixin, + JournalingMixin, + TagsMixin, + WebhooksMixin, +): + class Meta: + abstract = True + + class BigIDModel(models.Model): """ Abstract base model for all data objects. Ensures the use of a 64-bit PK. @@ -42,23 +53,17 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ - journal_entries = GenericRelation( - to='extras.JournalEntry', - object_id_field='assigned_object_id', - content_type_field='assigned_object_type' - ) - objects = RestrictedQuerySet.as_manager() class Meta: abstract = True -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -100,7 +105,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi }) -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 99f8c00b7..ed0e481ad 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,5 +1,6 @@ import logging +from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -20,6 +21,7 @@ __all__ = ( 'CustomValidationMixin', 'ExportTemplatesMixin', 'JobResultsMixin', + 'JournalingMixin', 'TagsMixin', 'WebhooksMixin', ) @@ -169,6 +171,20 @@ class JobResultsMixin(models.Model): abstract = True +class JournalingMixin(models.Model): + """ + Enables support for JournalEntry assignment. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + + class Meta: + abstract = True + + class TagsMixin(models.Model): """ Enable the assignment of Tags to a model. @@ -194,6 +210,7 @@ FEATURES_MAP = ( ('custom_links', CustomLinksMixin), ('export_templates', ExportTemplatesMixin), ('job_results', JobResultsMixin), + ('journaling', JournalingMixin), ('tags', TagsMixin), ('webhooks', WebhooksMixin), ) diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 42a7ffe7d..ecc599021 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,8 +4,8 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from netbox.models.features import WebhooksMixin from tenancy.choices import * __all__ = ( @@ -16,7 +16,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. @@ -50,7 +49,6 @@ class ContactGroup(NestedGroupModel): return reverse('tenancy:contactgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. @@ -78,7 +76,6 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. @@ -129,8 +126,7 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -@extras_features('webhooks') -class ContactAssignment(ChangeLoggedModel): +class ContactAssignment(WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index d480f9112..9952a700d 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel __all__ = ( @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. @@ -45,7 +43,6 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Tenant(PrimaryModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b19715127..d2f513f0b 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,7 +7,6 @@ from django.urls import reverse from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField @@ -15,7 +14,6 @@ from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from .choices import * - __all__ = ( 'Cluster', 'ClusterGroup', @@ -29,7 +27,6 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -61,7 +58,6 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. @@ -104,7 +100,6 @@ class ClusterGroup(OrganizationalModel): # Clusters # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Cluster(PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -188,7 +183,6 @@ class Cluster(PrimaryModel): # Virtual machines # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VirtualMachine(PrimaryModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. @@ -351,7 +345,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # Interfaces # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VMInterface(PrimaryModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 2fcfc97aa..843462ec6 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,7 +5,6 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from extras.utils import extras_features from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel from .choices import * from .constants import * @@ -41,7 +40,6 @@ class WirelessAuthenticationBase(models.Model): abstract = True -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLANGroup(NestedGroupModel): """ A nested grouping of WirelessLANs @@ -81,7 +79,6 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. @@ -120,7 +117,6 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslan', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. From 047bed2a865bcc69f0702ad9b06f664ab9ccc3c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 15:22:28 -0500 Subject: [PATCH 098/271] Tweak registry initialization --- netbox/extras/registry.py | 13 +++++++++++-- netbox/extras/utils.py | 8 +------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index cb58f5135..07fd4cc24 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -1,3 +1,8 @@ +import collections + +from extras.constants import EXTRAS_FEATURES + + class Registry(dict): """ Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or @@ -7,15 +12,19 @@ class Registry(dict): try: return super().__getitem__(key) except KeyError: - raise KeyError("Invalid store: {}".format(key)) + raise KeyError(f"Invalid store: {key}") def __setitem__(self, key, value): if key in self: - raise KeyError("Store already set: {}".format(key)) + raise KeyError(f"Store already set: {key}") super().__setitem__(key, value) def __delitem__(self, key): raise TypeError("Cannot delete stores from registry") +# Initialize the global registry registry = Registry() +registry['model_features'] = { + feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES +} diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 487ca3c0b..e16807821 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -import collections - from django.db.models import Q from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager @@ -58,12 +56,8 @@ class FeatureQuery: def register_features(model, features): - if 'model_features' not in registry: - registry['model_features'] = { - f: collections.defaultdict(list) for f in EXTRAS_FEATURES - } for feature in features: if feature not in EXTRAS_FEATURES: raise ValueError(f"{feature} is not a valid extras feature!") app_label, model_name = model._meta.label_lower.split('.') - registry['model_features'][feature][app_label].append(model_name) + registry['model_features'][feature][app_label].add(model_name) From dd552264559c968c94a725c9558a4fd01cd039c4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:44:18 -0500 Subject: [PATCH 099/271] Draft documentation for model features --- docs/plugins/development/index.md | 3 ++ docs/plugins/development/model-features.md | 37 ++++++++++++++++++++++ mkdocs.yml | 18 ++++++++++- netbox/netbox/models/features.py | 24 +++++++++++--- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 docs/plugins/development/index.md create mode 100644 docs/plugins/development/model-features.md diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md new file mode 100644 index 000000000..31ce5fc2e --- /dev/null +++ b/docs/plugins/development/index.md @@ -0,0 +1,3 @@ +# Plugins Development + +TODO diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md new file mode 100644 index 000000000..83f9b4205 --- /dev/null +++ b/docs/plugins/development/model-features.md @@ -0,0 +1,37 @@ +# Model Features + +Plugin models can leverage certain NetBox features by inheriting from designated Python classes (documented below), defined in `netbox.models.features`. These classes perform two crucial functions: + +1. Apply any fields, methods, or attributes necessary to the operation of the feature +2. Register the model with NetBox as utilizing the feature + +For example, to enable support for tags in a plugin model, it should inherit from `TagsMixin`: + +```python +# models.py +from django.db.models import models +from netbox.models.features import TagsMixin + +class MyModel(TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +This will ensure that TaggableManager is applied to the model, and that the model is registered with NetBox as taggable. + +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. + +::: netbox.models.features.CustomLinksMixin + +::: netbox.models.features.CustomFieldsMixin + +::: netbox.models.features.ExportTemplatesMixin + +::: netbox.models.features.JobResultsMixin + +::: netbox.models.features.JournalingMixin + +::: netbox.models.features.TagsMixin + +::: netbox.models.features.WebhooksMixin diff --git a/mkdocs.yml b/mkdocs.yml index f89bdaea7..764a04c86 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,19 @@ theme: toggle: icon: material/lightbulb name: Switch to Light Mode +plugins: + - mkdocstrings: + handlers: + python: + setup_commands: + - import os + - import django + - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") + - django.setup() + rendering: + show_root_heading: true + show_root_full_path: false + show_root_toc_entry: false extra: social: - icon: fontawesome/brands/github @@ -84,7 +97,10 @@ nav: - Webhooks: 'additional-features/webhooks.md' - Plugins: - Using Plugins: 'plugins/index.md' - - Developing Plugins: 'plugins/development.md' + - Developing Plugins: + - Introduction: 'plugins/development/index.md' + - Model Features: 'plugins/development/model-features.md' + - Developing Plugins (Old): 'plugins/development.md' - Administration: - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ed0e481ad..7865e3c8a 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -93,13 +93,25 @@ class CustomFieldsMixin(models.Model): @property def cf(self): """ - Convenience wrapper for custom field data. + A pass-through convenience alias for accessing `custom_field_data` (read-only). + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.cf + {'cust_id': 'CYB01'} + ``` """ return self.custom_field_data def get_custom_fields(self): """ - Return a dictionary of custom fields for a single object in the form {: value}. + Return a dictionary of custom fields for a single object in the form `{field: value}`. + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.get_custom_fields() + {: 'CYB01'} + ``` """ from extras.models import CustomField @@ -165,7 +177,7 @@ class ExportTemplatesMixin(models.Model): class JobResultsMixin(models.Model): """ - Enable the assignment of JobResults to a model. + Enables support for job results. """ class Meta: abstract = True @@ -173,7 +185,8 @@ class JobResultsMixin(models.Model): class JournalingMixin(models.Model): """ - Enables support for JournalEntry assignment. + Enables support for object journaling. Adds a generic relation (`journal_entries`) + to NetBox's JournalEntry model. """ journal_entries = GenericRelation( to='extras.JournalEntry', @@ -187,7 +200,8 @@ class JournalingMixin(models.Model): class TagsMixin(models.Model): """ - Enable the assignment of Tags to a model. + Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute, + which is a `TaggableManager` instance. """ tags = TaggableManager( through='extras.TaggedItem' From d104544d6f26f54d8982b385b7bf6aa31f86255d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:52:00 -0500 Subject: [PATCH 100/271] Add mkdocstrings --- docs/requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index c8726f8e6..b2e4e9a1b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,3 +5,7 @@ markdown-include # MkDocs Material theme (for documentation build) # https://github.com/squidfunk/mkdocs-material mkdocs-material + +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings From 196784474def8604dda5a74a13a9be29d2708dd9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 19 Jan 2022 16:58:06 -0500 Subject: [PATCH 101/271] Update documentation requirements --- base_requirements.txt | 4 ++++ docs/requirements.txt | 11 ----------- requirements.txt | 1 + 3 files changed, 5 insertions(+), 11 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/base_requirements.txt b/base_requirements.txt index aaa9c7f44..7ceb344b0 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -82,6 +82,10 @@ markdown-include # https://github.com/squidfunk/mkdocs-material mkdocs-material +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings + # Library for manipulating IP prefixes and addresses # https://github.com/drkjam/netaddr netaddr diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index b2e4e9a1b..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# File inclusion plugin for Python-Markdown -# https://github.com/cmacmackin/markdown-include -markdown-include - -# MkDocs Material theme (for documentation build) -# https://github.com/squidfunk/mkdocs-material -mkdocs-material - -# Introspection for embedded code -# https://github.com/mkdocstrings/mkdocstrings -mkdocstrings diff --git a/requirements.txt b/requirements.txt index 83c1b9f2e..a23be8637 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 mkdocs-material==8.1.7 +mkdocstrings==0.17.0 netaddr==0.8.0 Pillow==8.4.0 psycopg2-binary==2.9.3 From b7682ca9e829102b636864b23eaff15eb4562544 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 09:27:37 -0500 Subject: [PATCH 102/271] Fix documentation build --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 764a04c86..4fdf22f97 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ plugins: setup_commands: - import os - import django + - os.chdir('netbox/') - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - django.setup() rendering: From 1a8f144f5c2ca9d307f8ab7829f595a539206e81 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 10:53:00 -0500 Subject: [PATCH 103/271] Include custom validation in BaseModel --- docs/plugins/development/model-features.md | 46 ++++++++++++++++++---- mkdocs.yml | 1 + netbox/netbox/models/__init__.py | 7 ++-- netbox/netbox/models/features.py | 2 +- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md index 83f9b4205..7b70709d1 100644 --- a/docs/plugins/development/model-features.md +++ b/docs/plugins/development/model-features.md @@ -1,23 +1,51 @@ # Model Features -Plugin models can leverage certain NetBox features by inheriting from designated Python classes (documented below), defined in `netbox.models.features`. These classes perform two crucial functions: +## Enabling NetBox Features -1. Apply any fields, methods, or attributes necessary to the operation of the feature -2. Register the model with NetBox as utilizing the feature +Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: -For example, to enable support for tags in a plugin model, it should inherit from `TagsMixin`: +* Custom fields +* Custom links +* Custom validation +* Export templates +* Job results +* Journaling +* Tags +* Webhooks + +This class performs two crucial functions: + +1. Apply any fields, methods, or attributes necessary to the operation of these features +2. Register the model with NetBox as utilizing these feature + +Simply subclass BaseModel when defining a model in your plugin: ```python # models.py -from django.db.models import models -from netbox.models.features import TagsMixin +from netbox.models import BaseModel -class MyModel(TagsMixin, models.Model): +class MyModel(BaseModel): foo = models.CharField() ... ``` -This will ensure that TaggableManager is applied to the model, and that the model is registered with NetBox as taggable. +## Enabling Features Individually + +If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.) + +```python +# models.py +from django.db.models import models +from netbox.models.features import ExportTemplatesMixin, TagsMixin + +class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.) + +## Feature Mixins Reference !!! note Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. @@ -26,6 +54,8 @@ This will ensure that TaggableManager is applied to the model, and that the mode ::: netbox.models.features.CustomFieldsMixin +::: netbox.models.features.CustomValidationMixin + ::: netbox.models.features.ExportTemplatesMixin ::: netbox.models.features.JobResultsMixin diff --git a/mkdocs.yml b/mkdocs.yml index 4fdf22f97..585e6d76f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ plugins: - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - django.setup() rendering: + heading_level: 3 show_root_heading: true show_root_full_path: false show_root_toc_entry: false diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index e38412221..2db2e2602 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -22,6 +22,7 @@ __all__ = ( class BaseModel( CustomFieldsMixin, CustomLinksMixin, + CustomValidationMixin, ExportTemplatesMixin, JournalingMixin, TagsMixin, @@ -53,7 +54,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -63,7 +64,7 @@ class PrimaryModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDMo abstract = True -class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -105,7 +106,7 @@ class NestedGroupModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, Big }) -class OrganizationalModel(BaseModel, ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 7865e3c8a..ce3980459 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -155,7 +155,7 @@ class CustomLinksMixin(models.Model): class CustomValidationMixin(models.Model): """ - Enables user-configured validation rules for built-in models by extending the clean() method. + Enables user-configured validation rules for models. """ class Meta: abstract = True From e6acae5f94485a2eb459e17a0ed008f76fcc7226 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 11:41:00 -0500 Subject: [PATCH 104/271] Omit job results as a supported feature --- docs/plugins/development/model-features.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/plugins/development/model-features.md b/docs/plugins/development/model-features.md index 7b70709d1..35eb9389f 100644 --- a/docs/plugins/development/model-features.md +++ b/docs/plugins/development/model-features.md @@ -8,7 +8,6 @@ Plugin models can leverage certain NetBox features by inheriting from NetBox's ` * Custom links * Custom validation * Export templates -* Job results * Journaling * Tags * Webhooks @@ -58,8 +57,6 @@ The example above will enable export templates and tags, but no other NetBox fea ::: netbox.models.features.ExportTemplatesMixin -::: netbox.models.features.JobResultsMixin - ::: netbox.models.features.JournalingMixin ::: netbox.models.features.TagsMixin From 5f8870d448e26e46eed31c09c1546550b43e4765 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 20 Jan 2022 13:58:11 -0600 Subject: [PATCH 105/271] #7853 - Change Duplex Filterset to allow multivalues --- netbox/dcim/filtersets.py | 4 +++- netbox/dcim/forms/filtersets.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 8a83a8a6b..4dfb080bc 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1197,7 +1197,9 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT label='LAG interface (ID)', ) speed = MultiValueNumberFilter() - duplex = django_filters.CharFilter() + duplex = django_filters.MultipleChoiceFilter( + choices=InterfaceDuplexChoices + ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() tag = TagFilter() diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index ab7b9785a..8868cdf78 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -947,10 +947,11 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='Select Speed', widget=SelectSpeedWidget(attrs={'readonly': None}) ) - duplex = forms.ChoiceField( + duplex = forms.MultipleChoiceField( choices=InterfaceDuplexChoices, required=False, - label='Select Duplex' + label='Select Duplex', + widget=StaticSelectMultiple() ) enabled = forms.NullBooleanField( required=False, From 1a807416b849819758f59b3aafcacc9aad6488b8 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 20 Jan 2022 13:58:37 -0600 Subject: [PATCH 106/271] #7853 - Add columns to tables --- netbox/dcim/tables/devices.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f5ca49187..7b00a16e9 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -524,10 +524,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', - 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', - 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', - 'created', 'last_updated', + 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') From d0bfd7e19acead30eb943bd2e5089ac19cd67d63 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 20 Jan 2022 14:13:13 -0600 Subject: [PATCH 107/271] #7853 - Add tests --- netbox/dcim/tests/test_api.py | 4 ++++ netbox/dcim/tests/test_filtersets.py | 16 ++++++++++++---- netbox/dcim/tests/test_views.py | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1c6f53693..4ab682d74 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1442,6 +1442,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'speed': 1000000, + 'duplex': 'full' }, { 'device': device.pk, @@ -1454,6 +1456,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'speed': 100000, + 'duplex': 'half' }, { 'device': device.pk, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7b2e35009..de4806498 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2383,10 +2383,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0]), - Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1]), - Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2]), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), + Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'), + Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'), + Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'), + Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'), Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), @@ -2423,6 +2423,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_speed(self): + params = {'speed': [1000000, 100000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_duplex(self): + params = {'duplex': ['half', 'full']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_mgmt_only(self): params = {'mgmt_only': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 1b39285d4..4afa8a9f4 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2124,6 +2124,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 65000, + 'speed': 1000000, + 'duplex': 'full', 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -2145,6 +2147,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, + 'speed': 100000, + 'duplex': 'half', 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -2162,6 +2166,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, + 'speed': 1000000, + 'duplex': 'full', 'mgmt_only': True, 'description': 'New description', 'mode': InterfaceModeChoices.MODE_TAGGED, From 54834c47f8870e7faabcd847c3270da0bd3d2884 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 20 Jan 2022 16:31:55 -0500 Subject: [PATCH 108/271] Refactor generic views; add plugins dev documentation --- docs/plugins/development/generic-views.md | 91 +++++++ mkdocs.yml | 1 + netbox/netbox/views/generic/base.py | 15 ++ netbox/netbox/views/generic/bulk_views.py | 225 +++++++++++++--- netbox/netbox/views/generic/object_views.py | 282 +++++--------------- 5 files changed, 360 insertions(+), 254 deletions(-) create mode 100644 docs/plugins/development/generic-views.md create mode 100644 netbox/netbox/views/generic/base.py diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md new file mode 100644 index 000000000..ced7e3807 --- /dev/null +++ b/docs/plugins/development/generic-views.md @@ -0,0 +1,91 @@ +# Generic Views + +NetBox provides several generic view classes to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. + +| View Class | Description | +|------------|-------------| +| `ObjectView` | View a single object | +| `ObjectEditView` | Create or edit a single object | +| `ObjectDeleteView` | Delete a single object | +| `ObjectListView` | View a list of objects | +| `BulkImportView` | Import a set of new objects | +| `BulkEditView` | Edit multiple objects | +| `BulkDeleteView` | Delete multiple objects | + +### Example Usage + +```python +# views.py +from netbox.views.generic import ObjectEditView +from .models import Thing + +class ThingEditView(ObjectEditView): + queryset = Thing.objects.all() + template_name = 'myplugin/thing.html' + ... +``` + +## Generic Views Reference + +Below is the class definition for NetBox's base GenericView. The attributes and methods defined here are available on all generic views. + +::: netbox.views.generic.base.GenericView + rendering: + show_source: false + +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. + +::: netbox.views.generic.ObjectView + selection: + members: + - get_object + - get_template_name + - get_extra_context + rendering: + show_source: false + +::: netbox.views.generic.ObjectEditView + selection: + members: + - get_object + - alter_object + rendering: + show_source: false + +::: netbox.views.generic.ObjectDeleteView + selection: + members: + - get_object + rendering: + show_source: false + +::: netbox.views.generic.ObjectListView + selection: + members: + - get_table + - get_extra_context + - export_yaml + - export_table + - export_template + rendering: + show_source: false + +::: netbox.views.generic.BulkImportView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkEditView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkDeleteView + selection: + members: + - get_form + rendering: + show_source: false diff --git a/mkdocs.yml b/mkdocs.yml index 585e6d76f..c36d3f467 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -102,6 +102,7 @@ nav: - Developing Plugins: - Introduction: 'plugins/development/index.md' - Model Features: 'plugins/development/model-features.md' + - Generic Views: 'plugins/development/generic-views.md' - Developing Plugins (Old): 'plugins/development.md' - Administration: - Authentication: 'administration/authentication.md' diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py new file mode 100644 index 000000000..3861a93aa --- /dev/null +++ b/netbox/netbox/views/generic/base.py @@ -0,0 +1,15 @@ +from django.views.generic import View + +from utilities.views import ObjectPermissionRequiredMixin + + +class GenericView(ObjectPermissionRequiredMixin, View): + """ + Base view class for reusable generic views. + + Attributes: + queryset: Django QuerySet from which the object(s) will be fetched + template_name: The name of the HTML template file to render + """ + queryset = None + template_name = None diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index e9b213a95..c1ae2038e 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,21 +3,27 @@ import re from copy import deepcopy from django.contrib import messages +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea -from django.shortcuts import redirect, render -from django.views.generic import View +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django_tables2.export import TableExport +from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, ) +from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from utilities.tables import configure_table +from utilities.views import GetReturnURLMixin +from .base import GenericView __all__ = ( 'BulkComponentCreateView', @@ -26,24 +32,181 @@ __all__ = ( 'BulkEditView', 'BulkImportView', 'BulkRenameView', + 'ObjectListView', ) -class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectListView(GenericView): + """ + Display multiple objects, all of the same type, as a table. + + Attributes: + filterset: A django-filter FilterSet that is applied to the queryset + filterset_form: The form class used to render filter options + table: The django-tables2 Table used to render the objects list + action_buttons: A list of buttons to include at the top of the page + """ + template_name = 'generic/object_list.html' + filterset = None + filterset_form = None + table = None + action_buttons = ('add', 'import', 'export') + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'view') + + def get_table(self, request, permissions): + """ + Return the django-tables2 Table instance to be used for rendering the objects list. + + Args: + request: The current request + permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating + whether the user has each + """ + table = self.table(self.queryset, user=request.user) + if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): + table.columns.show('pk') + + return table + + def get_extra_context(self, request): + """ + Return any additional context data for the template. + + Agrs: + request: The current request + """ + return {} + + def get(self, request): + """ + GET request handler. + + Args: + request: The current request + """ + model = self.queryset.model + content_type = ContentType.objects.get_for_model(model) + + if self.filterset: + self.queryset = self.filterset(request.GET, self.queryset).qs + + # Compile a dictionary indicating which permissions are available to the current user for this model + permissions = {} + for action in ('add', 'change', 'delete', 'view'): + perm_name = get_permission_for_model(model, action) + permissions[action] = request.user.has_perm(perm_name) + + if 'export' in request.GET: + + # Export the current table view + if request.GET['export'] == 'table': + table = self.get_table(request, permissions) + columns = [name for name, _ in table.selected_columns] + return self.export_table(table, columns) + + # Render an ExportTemplate + elif request.GET['export']: + template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + return self.export_template(template, request) + + # Check for YAML export support on the model + elif hasattr(model, 'to_yaml'): + response = HttpResponse(self.export_yaml(), content_type='text/yaml') + filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response + + # Fall back to default table/YAML export + else: + table = self.get_table(request, permissions) + return self.export_table(table) + + # Render the objects table + table = self.get_table(request, permissions) + configure_table(table, request) + + # If this is an HTMX request, return only the rendered table HTML + if is_htmx(request): + return render(request, 'htmx/table.html', { + 'table': table, + }) + + context = { + 'content_type': content_type, + 'table': table, + 'permissions': permissions, + 'action_buttons': self.action_buttons, + 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, + } + context.update(self.get_extra_context(request)) + + return render(request, self.template_name, context) + + # + # Export methods + # + + def export_yaml(self): + """ + Export the queryset of objects as concatenated YAML documents. + """ + yaml_data = [obj.to_yaml() for obj in self.queryset] + + return '---\n'.join(yaml_data) + + def export_table(self, table, columns=None, filename=None): + """ + Export all table data in CSV format. + + Args: + table: The Table instance to export + columns: A list of specific columns to include. If None, all columns will be exported. + filename: The name of the file attachment sent to the client. If None, will be determined automatically + from the queryset model name. + """ + exclude_columns = {'pk', 'actions'} + if columns: + all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] + exclude_columns.update({ + col for col in all_columns if col not in columns + }) + exporter = TableExport( + export_format=TableExport.CSV, + table=table, + exclude_columns=exclude_columns + ) + return exporter.response( + filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' + ) + + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + + Args: + template: ExportTemplate instance + request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + +class BulkCreateView(GetReturnURLMixin, GenericView): """ Create new objects in bulk. - queryset: Base queryset for the objects being created form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects pattern_target: Name of the field to be evaluated as a pattern (if any) - template_name: The name of the template """ - queryset = None form = None model_form = None pattern_target = '' - template_name = None def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -135,20 +298,18 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkImportView(GetReturnURLMixin, GenericView): """ Import objects in bulk (CSV format). - queryset: Base queryset for the model - model_form: The form used to create each imported object - table: The django-tables2 Table used to render the list of imported objects - template_name: The name of the template - widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) + Attributes: + model_form: The form used to create each imported object + table: The django-tables2 Table used to render the list of imported objects + widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ - queryset = None + template_name = 'generic/object_bulk_import.html' model_form = None table = None - template_name = 'generic/object_bulk_import.html' widget_attrs = {} def _import_form(self, *args, **kwargs): @@ -265,21 +426,19 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkEditView(GetReturnURLMixin, GenericView): """ Edit objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being edited - form: The form class used to edit objects in bulk - template_name: The name of the template + Attributes: + filterset: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being edited + form: The form class used to edit objects in bulk """ - queryset = None + template_name = 'generic/object_bulk_edit.html' filterset = None table = None form = None - template_name = 'generic/object_bulk_edit.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') @@ -422,14 +581,10 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkRenameView(GetReturnURLMixin, GenericView): """ An extendable view for renaming objects in bulk. - - queryset: QuerySet of objects being renamed - template_name: The name of the template """ - queryset = None template_name = 'generic/object_bulk_rename.html' def __init__(self, *args, **kwargs): @@ -513,21 +668,18 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkDeleteView(GetReturnURLMixin, GenericView): """ Delete objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) filterset: FilterSet to apply when deleting by QuerySet table: The table used to display devices being deleted form: The form class used to delete objects in bulk - template_name: The name of the template """ - queryset = None + template_name = 'generic/object_bulk_delete.html' filterset = None table = None form = None - template_name = 'generic/object_bulk_delete.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') @@ -613,18 +765,17 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Device/VirtualMachine components # -class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkComponentCreateView(GetReturnURLMixin, GenericView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ + template_name = 'generic/object_bulk_add_component.html' parent_model = None parent_field = None form = None - queryset = None model_form = None filterset = None table = None - template_name = 'generic/object_bulk_add_component.html' def get_required_permission(self): return f'dcim.add_{self.queryset.model._meta.model_name}' diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index b6df5e3c2..79732572d 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -2,21 +2,16 @@ import logging from copy import deepcopy from django.contrib import messages -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError from django.forms.widgets import HiddenInput -from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe -from django.views.generic import View -from django_tables2.export import TableExport -from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortTransaction, PermissionsViolation @@ -25,7 +20,8 @@ from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from utilities.views import GetReturnURLMixin +from .base import GenericView __all__ = ( 'ComponentCreateView', @@ -33,27 +29,31 @@ __all__ = ( 'ObjectDeleteView', 'ObjectEditView', 'ObjectImportView', - 'ObjectListView', 'ObjectView', ) -class ObjectView(ObjectPermissionRequiredMixin, View): +class ObjectView(GenericView): """ Retrieve a single object for display. - queryset: The base queryset for retrieving the object - template_name: Name of the template to use + Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. """ - queryset = None - template_name = None def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') + def get_object(self, **kwargs): + """ + Return the object being viewed, identified by the keyword arguments passed. If no matching object is found, + raise a 404 error. + """ + return get_object_or_404(self.queryset, **kwargs) + def get_template_name(self): """ - Return self.template_name if set. Otherwise, resolve the template path by model app_label and name. + Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset + model's `app_label` and `model_name`. """ if self.template_name is not None: return self.template_name @@ -64,18 +64,20 @@ class ObjectView(ObjectPermissionRequiredMixin, View): """ Return any additional context data for the template. - :param request: The current request - :param instance: The object being viewed + Args: + request: The current request + instance: The object being viewed """ return {} - def get(self, request, *args, **kwargs): + def get(self, request, **kwargs): """ - GET request handler. *args and **kwargs are passed to identify the object being queried. + GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. - :param request: The current request + Args: + request: The current request """ - instance = get_object_or_404(self.queryset, **kwargs) + instance = self.get_object(**kwargs) return render(request, self.get_template_name(), { 'object': instance, @@ -87,15 +89,12 @@ class ObjectChildrenView(ObjectView): """ Display a table of child objects associated with the parent object. - queryset: The base queryset for retrieving the *parent* object - table: Table class used to render child objects list - template_name: Name of the template to use + Attributes: + table: Table class used to render child objects list """ - queryset = None child_model = None table = None filterset = None - template_name = None def get_children(self, request, parent): """ @@ -110,9 +109,10 @@ class ObjectChildrenView(ObjectView): """ Provides a hook for subclassed views to modify data before initializing the table. - :param request: The current request - :param queryset: The filtered queryset of child objects - :param parent: The parent object + Args: + request: The current request + queryset: The filtered queryset of child objects + parent: The parent object """ return queryset @@ -120,7 +120,7 @@ class ObjectChildrenView(ObjectView): """ GET handler for rendering child objects. """ - instance = get_object_or_404(self.queryset, **kwargs) + instance = self.get_object(**kwargs) child_objects = self.get_children(request, instance) if self.filterset: @@ -152,171 +152,17 @@ class ObjectChildrenView(ObjectView): }) -class ObjectListView(ObjectPermissionRequiredMixin, View): - """ - List a series of objects. - - queryset: The queryset of objects to display. Note: Prefetching related objects is not necessary, as the - table will prefetch objects as needed depending on the columns being displayed. - filterset: A django-filter FilterSet that is applied to the queryset - filterset_form: The form used to render filter options - table: The django-tables2 Table used to render the objects list - template_name: The name of the template - action_buttons: A list of buttons to include at the top of the page - """ - queryset = None - filterset = None - filterset_form = None - table = None - template_name = 'generic/object_list.html' - action_buttons = ('add', 'import', 'export') - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'view') - - def get_table(self, request, permissions): - """ - Return the django-tables2 Table instance to be used for rendering the objects list. - - :param request: The current request - :param permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating - whether the user has each - """ - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') - - return table - - def export_yaml(self): - """ - Export the queryset of objects as concatenated YAML documents. - """ - yaml_data = [obj.to_yaml() for obj in self.queryset] - - return '---\n'.join(yaml_data) - - def export_table(self, table, columns=None): - """ - Export all table data in CSV format. - - :param table: The Table instance to export - :param columns: A list of specific columns to include. If not specified, all columns will be exported. - """ - exclude_columns = {'pk', 'actions'} - if columns: - all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] - exclude_columns.update({ - col for col in all_columns if col not in columns - }) - exporter = TableExport( - export_format=TableExport.CSV, - table=table, - exclude_columns=exclude_columns - ) - return exporter.response( - filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' - ) - - def export_template(self, template, request): - """ - Render an ExportTemplate using the current queryset. - - :param template: ExportTemplate instance - :param request: The current request - """ - try: - return template.render_to_response(self.queryset) - except Exception as e: - messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") - return redirect(request.path) - - def get_extra_context(self, request): - """ - Return any additional context data for the template. - - :param request: The current request - """ - return {} - - def get(self, request): - """ - GET request handler. - - :param request: The current request - """ - model = self.queryset.model - content_type = ContentType.objects.get_for_model(model) - - if self.filterset: - self.queryset = self.filterset(request.GET, self.queryset).qs - - # Compile a dictionary indicating which permissions are available to the current user for this model - permissions = {} - for action in ('add', 'change', 'delete', 'view'): - perm_name = get_permission_for_model(model, action) - permissions[action] = request.user.has_perm(perm_name) - - if 'export' in request.GET: - - # Export the current table view - if request.GET['export'] == 'table': - table = self.get_table(request, permissions) - columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) - - # Render an ExportTemplate - elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) - return self.export_template(template, request) - - # Check for YAML export support on the model - elif hasattr(model, 'to_yaml'): - response = HttpResponse(self.export_yaml(), content_type='text/yaml') - filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - return response - - # Fall back to default table/YAML export - else: - table = self.get_table(request, permissions) - return self.export_table(table) - - # Render the objects table - table = self.get_table(request, permissions) - configure_table(table, request) - - # If this is an HTMX request, return only the rendered table HTML - if is_htmx(request): - return render(request, 'htmx/table.html', { - 'table': table, - }) - - context = { - 'content_type': content_type, - 'table': table, - 'permissions': permissions, - 'action_buttons': self.action_buttons, - 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, - } - context.update(self.get_extra_context(request)) - - return render(request, self.template_name, context) - - -class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectImportView(GetReturnURLMixin, GenericView): """ Import a single object (YAML or JSON format). - queryset: Base queryset for the objects being created - model_form: The ModelForm used to create individual objects - related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects - template_name: The name of the template + Attributes: + model_form: The ModelForm used to create individual objects + related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects """ - queryset = None + template_name = 'generic/object_import.html' model_form = None related_object_forms = dict() - template_name = 'generic/object_import.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -445,17 +291,21 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectEditView(GetReturnURLMixin, GenericView): """ Create or edit a single object. - queryset: The base QuerySet for the object being modified - model_form: The form used to create or edit the object - template_name: The name of the template + Attributes: + model_form: The form used to create or edit the object """ - queryset = None - model_form = None template_name = 'generic/object_edit.html' + model_form = None + + def dispatch(self, request, *args, **kwargs): + # Determine required permission based on whether we are editing an existing object + self._permission_action = 'change' if kwargs else 'add' + + return super().dispatch(request, *args, **kwargs) def get_required_permission(self): # self._permission_action is set by dispatch() to either "add" or "change" depending on whether @@ -466,13 +316,16 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Return an instance for editing. If a PK has been specified, this will be an existing object. - :param kwargs: URL path kwargs + Args: + kwargs: URL path kwargs """ if 'pk' in kwargs: obj = get_object_or_404(self.queryset, **kwargs) + # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): obj.snapshot() + return obj return self.queryset.model() @@ -482,24 +335,20 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): Provides a hook for views to modify an object before it is processed. For example, a parent object can be defined given some parameter from the request URL. - :param obj: The object being edited - :param request: The current request - :param url_args: URL path args - :param url_kwargs: URL path kwargs + Args: + obj: The object being edited + request: The current request + url_args: URL path args + url_kwargs: URL path kwargs """ return obj - def dispatch(self, request, *args, **kwargs): - # Determine required permission based on whether we are editing an existing object - self._permission_action = 'change' if kwargs else 'add' - - return super().dispatch(request, *args, **kwargs) - def get(self, request, *args, **kwargs): """ GET request handler. - :param request: The current request + Args: + request: The current request """ obj = self.get_object(**kwargs) obj = self.alter_object(obj, request, args, kwargs) @@ -519,7 +368,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ POST request handler. - :param request: The current request + Args: + request: The current request """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) @@ -588,14 +438,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectDeleteView(GetReturnURLMixin, GenericView): """ Delete a single object. - - queryset: The base queryset for the object being deleted - template_name: The name of the template """ - queryset = None template_name = 'generic/object_delete.html' def get_required_permission(self): @@ -605,7 +451,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Return an instance for deletion. If a PK has been specified, this will be an existing object. - :param kwargs: URL path kwargs + Args: + kwargs: URL path kwargs """ obj = get_object_or_404(self.queryset, **kwargs) @@ -619,7 +466,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ GET request handler. - :param request: The current request + Args: + request: The current request """ obj = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) @@ -646,7 +494,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ POST request handler. - :param request: The current request + Args: + request: The current request """ logger = logging.getLogger('netbox.views.ObjectDeleteView') obj = self.get_object(**kwargs) @@ -687,14 +536,13 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Device/VirtualMachine components # -class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ComponentCreateView(GetReturnURLMixin, GenericView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ - queryset = None + template_name = 'dcim/component_create.html' form = None model_form = None - template_name = 'dcim/component_create.html' patterned_fields = ('name', 'label') def get_required_permission(self): From e03593d86f3082c19255ae24f39d1ed860a04c4d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 13:56:58 -0500 Subject: [PATCH 109/271] Move get_extra_context() to base views --- docs/plugins/development/generic-views.md | 22 +++++--- netbox/netbox/views/generic/base.py | 33 +++++++++++- netbox/netbox/views/generic/bulk_views.py | 57 ++++++++++----------- netbox/netbox/views/generic/object_views.py | 28 ++++------ 4 files changed, 84 insertions(+), 56 deletions(-) diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md index ced7e3807..d8ad0e7a4 100644 --- a/docs/plugins/development/generic-views.md +++ b/docs/plugins/development/generic-views.md @@ -12,6 +12,9 @@ NetBox provides several generic view classes to facilitate common operations, su | `BulkEditView` | Edit multiple objects | | `BulkDeleteView` | Delete multiple objects | +!!! note + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. + ### Example Usage ```python @@ -25,23 +28,19 @@ class ThingEditView(ObjectEditView): ... ``` -## Generic Views Reference +## Object Views -Below is the class definition for NetBox's base GenericView. The attributes and methods defined here are available on all generic views. +Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object. -::: netbox.views.generic.base.GenericView +::: netbox.views.generic.base.BaseObjectView rendering: show_source: false -!!! note - Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. - ::: netbox.views.generic.ObjectView selection: members: - get_object - get_template_name - - get_extra_context rendering: show_source: false @@ -60,11 +59,18 @@ Below is the class definition for NetBox's base GenericView. The attributes and rendering: show_source: false +## Multi-Object Views + +Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects. + +::: netbox.views.generic.base.BaseMultiObjectView + rendering: + show_source: false + ::: netbox.views.generic.ObjectListView selection: members: - get_table - - get_extra_context - export_yaml - export_table - export_template diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 3861a93aa..7d7c305dd 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -3,7 +3,7 @@ from django.views.generic import View from utilities.views import ObjectPermissionRequiredMixin -class GenericView(ObjectPermissionRequiredMixin, View): +class BaseObjectView(ObjectPermissionRequiredMixin, View): """ Base view class for reusable generic views. @@ -13,3 +13,34 @@ class GenericView(ObjectPermissionRequiredMixin, View): """ queryset = None template_name = None + + def get_extra_context(self, request, instance): + """ + Return any additional context data to include when rendering the template. + + Args: + request: The current request + instance: The object being viewed + """ + return {} + + +class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): + """ + Base view class for reusable generic views. + + Attributes: + queryset: Django QuerySet from which the object(s) will be fetched + template_name: The name of the HTML template file to render + """ + queryset = None + template_name = None + + def get_extra_context(self, request): + """ + Return any additional context data to include when rendering the template. + + Args: + request: The current request + """ + return {} diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index c1ae2038e..3025818fa 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -23,7 +23,7 @@ from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.tables import configure_table from utilities.views import GetReturnURLMixin -from .base import GenericView +from .base import BaseMultiObjectView __all__ = ( 'BulkComponentCreateView', @@ -36,7 +36,7 @@ __all__ = ( ) -class ObjectListView(GenericView): +class ObjectListView(BaseMultiObjectView): """ Display multiple objects, all of the same type, as a table. @@ -70,15 +70,6 @@ class ObjectListView(GenericView): return table - def get_extra_context(self, request): - """ - Return any additional context data for the template. - - Agrs: - request: The current request - """ - return {} - def get(self, request): """ GET request handler. @@ -139,8 +130,8 @@ class ObjectListView(GenericView): 'permissions': permissions, 'action_buttons': self.action_buttons, 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, + **self.get_extra_context(request), } - context.update(self.get_extra_context(request)) return render(request, self.template_name, context) @@ -196,7 +187,7 @@ class ObjectListView(GenericView): return redirect(request.path) -class BulkCreateView(GetReturnURLMixin, GenericView): +class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): """ Create new objects in bulk. @@ -251,6 +242,7 @@ class BulkCreateView(GetReturnURLMixin, GenericView): 'form': form, 'model_form': model_form, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) def post(self, request): @@ -295,10 +287,11 @@ class BulkCreateView(GetReturnURLMixin, GenericView): 'model_form': model_form, 'obj_type': model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkImportView(GetReturnURLMixin, GenericView): +class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): """ Import objects in bulk (CSV format). @@ -375,6 +368,7 @@ class BulkImportView(GetReturnURLMixin, GenericView): 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) def post(self, request): @@ -423,10 +417,11 @@ class BulkImportView(GetReturnURLMixin, GenericView): 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkEditView(GetReturnURLMixin, GenericView): +class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): """ Edit objects in bulk. @@ -578,10 +573,11 @@ class BulkEditView(GetReturnURLMixin, GenericView): 'table': table, 'obj_type_plural': model._meta.verbose_name_plural, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkRenameView(GetReturnURLMixin, GenericView): +class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): """ An extendable view for renaming objects in bulk. """ @@ -668,7 +664,7 @@ class BulkRenameView(GetReturnURLMixin, GenericView): }) -class BulkDeleteView(GetReturnURLMixin, GenericView): +class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): """ Delete objects in bulk. @@ -684,6 +680,18 @@ class BulkDeleteView(GetReturnURLMixin, GenericView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') + def get_form(self): + """ + Provide a standard bulk delete form if none has been specified for the view + """ + class BulkDeleteForm(ConfirmationForm): + pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) + + if self.form: + return self.form + + return BulkDeleteForm + def get(self, request): return redirect(self.get_return_url(request)) @@ -746,26 +754,15 @@ class BulkDeleteView(GetReturnURLMixin, GenericView): 'obj_type_plural': model._meta.verbose_name_plural, 'table': table, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) - def get_form(self): - """ - Provide a standard bulk delete form if none has been specified for the view - """ - class BulkDeleteForm(ConfirmationForm): - pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) - - if self.form: - return self.form - - return BulkDeleteForm - # # Device/VirtualMachine components # -class BulkComponentCreateView(GetReturnURLMixin, GenericView): +class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 79732572d..c681767c2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -21,7 +21,7 @@ from utilities.permissions import get_permission_for_model from utilities.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin -from .base import GenericView +from .base import BaseObjectView __all__ = ( 'ComponentCreateView', @@ -33,13 +33,12 @@ __all__ = ( ) -class ObjectView(GenericView): +class ObjectView(BaseObjectView): """ Retrieve a single object for display. Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. """ - def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') @@ -60,16 +59,6 @@ class ObjectView(GenericView): model_opts = self.queryset.model._meta return f'{model_opts.app_label}/{model_opts.model_name}.html' - def get_extra_context(self, request, instance): - """ - Return any additional context data for the template. - - Args: - request: The current request - instance: The object being viewed - """ - return {} - def get(self, request, **kwargs): """ GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. @@ -152,7 +141,7 @@ class ObjectChildrenView(ObjectView): }) -class ObjectImportView(GetReturnURLMixin, GenericView): +class ObjectImportView(GetReturnURLMixin, BaseObjectView): """ Import a single object (YAML or JSON format). @@ -291,7 +280,7 @@ class ObjectImportView(GetReturnURLMixin, GenericView): }) -class ObjectEditView(GetReturnURLMixin, GenericView): +class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ Create or edit a single object. @@ -362,6 +351,7 @@ class ObjectEditView(GetReturnURLMixin, GenericView): 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) def post(self, request, *args, **kwargs): @@ -435,10 +425,11 @@ class ObjectEditView(GetReturnURLMixin, GenericView): 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) -class ObjectDeleteView(GetReturnURLMixin, GenericView): +class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ Delete a single object. """ @@ -481,6 +472,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'form_url': form_url, + **self.get_extra_context(request, obj), }) return render(request, self.template_name, { @@ -488,6 +480,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) def post(self, request, *args, **kwargs): @@ -529,6 +522,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) @@ -536,7 +530,7 @@ class ObjectDeleteView(GetReturnURLMixin, GenericView): # Device/VirtualMachine components # -class ComponentCreateView(GetReturnURLMixin, GenericView): +class ComponentCreateView(GetReturnURLMixin, BaseObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ From a74ed33b0ed53eddddad615835adc42534d246cc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 14:41:37 -0500 Subject: [PATCH 110/271] Move get_object() to BaseObjectView --- docs/plugins/development/generic-views.md | 1 - mkdocs.yml | 1 + netbox/netbox/views/generic/base.py | 12 ++ netbox/netbox/views/generic/bulk_views.py | 128 +++++++++++--------- netbox/netbox/views/generic/object_views.py | 70 ++++++----- 5 files changed, 118 insertions(+), 94 deletions(-) diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md index d8ad0e7a4..1a444ca2c 100644 --- a/docs/plugins/development/generic-views.md +++ b/docs/plugins/development/generic-views.md @@ -71,7 +71,6 @@ Below is the class definition for NetBox's BaseMultiObjectView. The attributes a selection: members: - get_table - - export_yaml - export_table - export_template rendering: diff --git a/mkdocs.yml b/mkdocs.yml index c36d3f467..dbd31cb50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ plugins: - django.setup() rendering: heading_level: 3 + members_order: source show_root_heading: true show_root_full_path: false show_root_toc_entry: false diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py index 7d7c305dd..3ad3bcf67 100644 --- a/netbox/netbox/views/generic/base.py +++ b/netbox/netbox/views/generic/base.py @@ -1,3 +1,4 @@ +from django.shortcuts import get_object_or_404 from django.views.generic import View from utilities.views import ObjectPermissionRequiredMixin @@ -14,6 +15,15 @@ class BaseObjectView(ObjectPermissionRequiredMixin, View): queryset = None template_name = None + def get_object(self, **kwargs): + """ + Return the object being viewed or modified. The object is identified by an arbitrary set of keyword arguments + gleaned from the URL, which are passed to `get_object_or_404()`. (Typically, only a primary key is needed.) + + If no matching object is found, return a 404 response. + """ + return get_object_or_404(self.queryset, **kwargs) + def get_extra_context(self, request, instance): """ Return any additional context data to include when rendering the template. @@ -31,9 +41,11 @@ class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): Attributes: queryset: Django QuerySet from which the object(s) will be fetched + table: The django-tables2 Table class used to render the objects list template_name: The name of the HTML template file to render """ queryset = None + table = None template_name = None def get_extra_context(self, request): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 3025818fa..5286de314 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -43,13 +43,11 @@ class ObjectListView(BaseMultiObjectView): Attributes: filterset: A django-filter FilterSet that is applied to the queryset filterset_form: The form class used to render filter options - table: The django-tables2 Table used to render the objects list action_buttons: A list of buttons to include at the top of the page """ template_name = 'generic/object_list.html' filterset = None filterset_form = None - table = None action_buttons = ('add', 'import', 'export') def get_required_permission(self): @@ -70,6 +68,61 @@ class ObjectListView(BaseMultiObjectView): return table + # + # Export methods + # + + def export_yaml(self): + """ + Export the queryset of objects as concatenated YAML documents. + """ + yaml_data = [obj.to_yaml() for obj in self.queryset] + + return '---\n'.join(yaml_data) + + def export_table(self, table, columns=None, filename=None): + """ + Export all table data in CSV format. + + Args: + table: The Table instance to export + columns: A list of specific columns to include. If None, all columns will be exported. + filename: The name of the file attachment sent to the client. If None, will be determined automatically + from the queryset model name. + """ + exclude_columns = {'pk', 'actions'} + if columns: + all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] + exclude_columns.update({ + col for col in all_columns if col not in columns + }) + exporter = TableExport( + export_format=TableExport.CSV, + table=table, + exclude_columns=exclude_columns + ) + return exporter.response( + filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' + ) + + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + + Args: + template: ExportTemplate instance + request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + # + # Request handlers + # + def get(self, request): """ GET request handler. @@ -135,57 +188,6 @@ class ObjectListView(BaseMultiObjectView): return render(request, self.template_name, context) - # - # Export methods - # - - def export_yaml(self): - """ - Export the queryset of objects as concatenated YAML documents. - """ - yaml_data = [obj.to_yaml() for obj in self.queryset] - - return '---\n'.join(yaml_data) - - def export_table(self, table, columns=None, filename=None): - """ - Export all table data in CSV format. - - Args: - table: The Table instance to export - columns: A list of specific columns to include. If None, all columns will be exported. - filename: The name of the file attachment sent to the client. If None, will be determined automatically - from the queryset model name. - """ - exclude_columns = {'pk', 'actions'} - if columns: - all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] - exclude_columns.update({ - col for col in all_columns if col not in columns - }) - exporter = TableExport( - export_format=TableExport.CSV, - table=table, - exclude_columns=exclude_columns - ) - return exporter.response( - filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' - ) - - def export_template(self, template, request): - """ - Render an ExportTemplate using the current queryset. - - Args: - template: ExportTemplate instance - request: The current request - """ - try: - return template.render_to_response(self.queryset) - except Exception as e: - messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") - return redirect(request.path) - class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): """ @@ -227,6 +229,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): return new_objects + # + # Request handlers + # + def get(self, request): # Set initial values for visible form fields from query args initial = {} @@ -297,12 +303,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): Attributes: model_form: The form used to create each imported object - table: The django-tables2 Table used to render the list of imported objects widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ template_name = 'generic/object_bulk_import.html' model_form = None - table = None widget_attrs = {} def _import_form(self, *args, **kwargs): @@ -361,6 +365,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + # + # Request handlers + # + def get(self, request): return render(request, self.template_name, { @@ -427,12 +435,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): Attributes: filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being edited form: The form class used to edit objects in bulk """ template_name = 'generic/object_bulk_edit.html' filterset = None - table = None form = None def get_required_permission(self): @@ -495,6 +501,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): return updated_objects + # + # Request handlers + # + def get(self, request): return redirect(self.get_return_url(request)) @@ -692,6 +702,10 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): return BulkDeleteForm + # + # Request handlers + # + def get(self, request): return redirect(self.get_return_url(request)) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index c681767c2..09a102442 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -6,7 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError from django.forms.widgets import HiddenInput -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url @@ -42,13 +42,6 @@ class ObjectView(BaseObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') - def get_object(self, **kwargs): - """ - Return the object being viewed, identified by the keyword arguments passed. If no matching object is found, - raise a 404 error. - """ - return get_object_or_404(self.queryset, **kwargs) - def get_template_name(self): """ Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset @@ -59,6 +52,10 @@ class ObjectView(BaseObjectView): model_opts = self.queryset.model._meta return f'{model_opts.app_label}/{model_opts.model_name}.html' + # + # Request handlers + # + def get(self, request, **kwargs): """ GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. @@ -105,6 +102,10 @@ class ObjectChildrenView(ObjectView): """ return queryset + # + # Request handlers + # + def get(self, request, *args, **kwargs): """ GET handler for rendering child objects. @@ -202,6 +203,10 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): return obj + # + # Request handlers + # + def get(self, request): form = ImportForm() @@ -303,21 +308,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): def get_object(self, **kwargs): """ - Return an instance for editing. If a PK has been specified, this will be an existing object. - - Args: - kwargs: URL path kwargs + Return an object for editing. If no keyword arguments have been specified, this will be a new instance. """ - if 'pk' in kwargs: - obj = get_object_or_404(self.queryset, **kwargs) - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - return obj - - return self.queryset.model() + if not kwargs: + # We're creating a new object + return self.queryset.model() + return super().get_object(**kwargs) def alter_object(self, obj, request, url_args, url_kwargs): """ @@ -332,6 +328,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ return obj + # + # Request handlers + # + def get(self, request, *args, **kwargs): """ GET request handler. @@ -363,6 +363,11 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) + + # Take a snapshot for change logging (if editing an existing object) + if obj.pk and hasattr(obj, 'snapshot'): + obj.snapshot() + obj = self.alter_object(obj, request, args, kwargs) form = self.model_form( @@ -438,20 +443,9 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') - def get_object(self, **kwargs): - """ - Return an instance for deletion. If a PK has been specified, this will be an existing object. - - Args: - kwargs: URL path kwargs - """ - obj = get_object_or_404(self.queryset, **kwargs) - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - return obj + # + # Request handlers + # def get(self, request, *args, **kwargs): """ @@ -494,6 +488,10 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): obj = self.get_object(**kwargs) form = ConfirmationForm(request.POST) + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + if form.is_valid(): logger.debug("Form validation was successful") From 1c946250424e849dadcf91bef5db7cc17b222518 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 14:48:27 -0500 Subject: [PATCH 111/271] Remove widget_attrs from BulkImportView --- netbox/netbox/views/generic/bulk_views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 5286de314..82e1dc217 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -303,18 +303,15 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): Attributes: model_form: The form used to create each imported object - widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ template_name = 'generic/object_bulk_import.html' model_form = None - widget_attrs = {} def _import_form(self, *args, **kwargs): class ImportForm(BootstrapMixin, Form): csv = CSVDataField( - from_form=self.model_form, - widget=Textarea(attrs=self.widget_attrs) + from_form=self.model_form ) csv_file = CSVFileField( label="CSV file", From 5abfe821bc50084a5bff4a3f29a7168dd844b326 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 21 Jan 2022 15:43:53 -0500 Subject: [PATCH 112/271] Changelog cnad cleanup for #7853 --- docs/release-notes/version-3.2.md | 3 ++- netbox/dcim/forms/bulk_import.py | 3 +-- netbox/dcim/models/device_components.py | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 432d6fafc..c35806c04 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -69,6 +69,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs +* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to interface model * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group * [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links @@ -100,7 +101,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * dcim.FrontPort * Added `module` field * dcim.Interface - * Added `module` and `vrf` fields + * Added `module`, `speed`, `duplex`, and `vrf` fields * dcim.InventoryItem * Added `component_type`, `component_id`, and `role` fields * Added read-only `component` field diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index acce43be0..1aec329eb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -620,8 +620,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): ) duplex = CSVChoiceField( choices=InterfaceDuplexChoices, - required=False, - help_text='Duplex' + required=False ) mode = CSVChoiceField( choices=InterfaceModeChoices, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index cae0d1150..4a68f7c8d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -546,12 +546,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo help_text='This interface is used only for out-of-band management' ) speed = models.PositiveIntegerField( - verbose_name='Speed', blank=True, null=True ) duplex = models.CharField( - verbose_name='Duplex', max_length=50, blank=True, null=True, From 571e9801f3c42193e8e26970d63fc0bf9933c774 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 24 Jan 2022 16:02:54 -0500 Subject: [PATCH 113/271] Closes #8195: Ensure all GenericForeignKey ID fields employ PositiveBigIntegerField --- .../migrations/0033_gfk_bigidfield.py | 18 +++++ netbox/dcim/migrations/0151_gfk_bigidfield.py | 73 +++++++++++++++++++ netbox/dcim/models/cables.py | 8 +- netbox/dcim/models/device_components.py | 2 +- .../extras/migrations/0071_gfk_bigidfield.py | 33 +++++++++ netbox/extras/models/change_logging.py | 4 +- netbox/extras/models/models.py | 4 +- netbox/ipam/migrations/0056_gfk_bigidfield.py | 23 ++++++ netbox/ipam/models/fhrp.py | 2 +- netbox/ipam/models/ip.py | 2 +- .../tenancy/migrations/0005_gfk_bigidfield.py | 18 +++++ netbox/tenancy/models/contacts.py | 2 +- 12 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 netbox/circuits/migrations/0033_gfk_bigidfield.py create mode 100644 netbox/dcim/migrations/0151_gfk_bigidfield.py create mode 100644 netbox/extras/migrations/0071_gfk_bigidfield.py create mode 100644 netbox/ipam/migrations/0056_gfk_bigidfield.py create mode 100644 netbox/tenancy/migrations/0005_gfk_bigidfield.py diff --git a/netbox/circuits/migrations/0033_gfk_bigidfield.py b/netbox/circuits/migrations/0033_gfk_bigidfield.py new file mode 100644 index 000000000..970617a88 --- /dev/null +++ b/netbox/circuits/migrations/0033_gfk_bigidfield.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0032_provider_service_id'), + ] + + operations = [ + migrations.AlterField( + model_name='circuittermination', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0151_gfk_bigidfield.py b/netbox/dcim/migrations/0151_gfk_bigidfield.py new file mode 100644 index 000000000..733e6ecd5 --- /dev/null +++ b/netbox/dcim/migrations/0151_gfk_bigidfield.py @@ -0,0 +1,73 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0150_interface_speed_duplex'), + ] + + operations = [ + migrations.AlterField( + model_name='cable', + name='termination_a_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cable', + name='termination_b_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cablepath', + name='destination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cablepath', + name='origin_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='consoleport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 18bf65895..e3cc20177 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -38,7 +38,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_a_id = models.PositiveIntegerField() + termination_a_id = models.PositiveBigIntegerField() termination_a = GenericForeignKey( ct_field='termination_a_type', fk_field='termination_a_id' @@ -49,7 +49,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_b_id = models.PositiveIntegerField() + termination_b_id = models.PositiveBigIntegerField() termination_b = GenericForeignKey( ct_field='termination_b_type', fk_field='termination_b_id' @@ -327,7 +327,7 @@ class CablePath(BigIDModel): on_delete=models.CASCADE, related_name='+' ) - origin_id = models.PositiveIntegerField() + origin_id = models.PositiveBigIntegerField() origin = GenericForeignKey( ct_field='origin_type', fk_field='origin_id' @@ -339,7 +339,7 @@ class CablePath(BigIDModel): blank=True, null=True ) - destination_id = models.PositiveIntegerField( + destination_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4a68f7c8d..9071dfe46 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -130,7 +130,7 @@ class LinkTermination(models.Model): blank=True, null=True ) - _link_peer_id = models.PositiveIntegerField( + _link_peer_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/extras/migrations/0071_gfk_bigidfield.py b/netbox/extras/migrations/0071_gfk_bigidfield.py new file mode 100644 index 000000000..64ce3c471 --- /dev/null +++ b/netbox/extras/migrations/0071_gfk_bigidfield.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0070_customlink_enabled'), + ] + + operations = [ + migrations.AlterField( + model_name='imageattachment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='journalentry', + name='assigned_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='changed_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='related_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 8dfeb2f18..4e703833a 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -43,7 +43,7 @@ class ObjectChange(BigIDModel): on_delete=models.PROTECT, related_name='+' ) - changed_object_id = models.PositiveIntegerField() + changed_object_id = models.PositiveBigIntegerField() changed_object = GenericForeignKey( ct_field='changed_object_type', fk_field='changed_object_id' @@ -55,7 +55,7 @@ class ObjectChange(BigIDModel): blank=True, null=True ) - related_object_id = models.PositiveIntegerField( + related_object_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 7189aed03..143bc7d9b 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -351,7 +351,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() + object_id = models.PositiveBigIntegerField() parent = GenericForeignKey( ct_field='content_type', fk_field='object_id' @@ -431,7 +431,7 @@ class JournalEntry(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - assigned_object_id = models.PositiveIntegerField() + assigned_object_id = models.PositiveBigIntegerField() assigned_object = GenericForeignKey( ct_field='assigned_object_type', fk_field='assigned_object_id' diff --git a/netbox/ipam/migrations/0056_gfk_bigidfield.py b/netbox/ipam/migrations/0056_gfk_bigidfield.py new file mode 100644 index 000000000..f40f65271 --- /dev/null +++ b/netbox/ipam/migrations/0056_gfk_bigidfield.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0055_servicetemplate'), + ] + + operations = [ + migrations.AlterField( + model_name='fhrpgroupassignment', + name='interface_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index a0e575e45..f0e3c2a23 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -74,7 +74,7 @@ class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - interface_id = models.PositiveIntegerField() + interface_id = models.PositiveBigIntegerField() interface = GenericForeignKey( ct_field='interface_type', fk_field='interface_id' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 44dd84525..632d71034 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -801,7 +801,7 @@ class IPAddress(PrimaryModel): blank=True, null=True ) - assigned_object_id = models.PositiveIntegerField( + assigned_object_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/tenancy/migrations/0005_gfk_bigidfield.py b/netbox/tenancy/migrations/0005_gfk_bigidfield.py new file mode 100644 index 000000000..12bbde295 --- /dev/null +++ b/netbox/tenancy/migrations/0005_gfk_bigidfield.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-24 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ] + + operations = [ + migrations.AlterField( + model_name='contactassignment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index ecc599021..cacd682cb 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -131,7 +131,7 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() + object_id = models.PositiveBigIntegerField() object = GenericForeignKey( ct_field='content_type', fk_field='object_id' From 497afcc1e451803f6ef741891ccecf71173b3c4e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 13:53:31 -0500 Subject: [PATCH 114/271] Rearrange plugins documentation --- docs/media/plugins/plugin_admin_ui.png | Bin 23831 -> 0 bytes docs/plugins/development.md | 433 ------------------- docs/plugins/development/background-tasks.md | 27 ++ docs/plugins/development/generic-views.md | 96 ---- docs/plugins/development/index.md | 145 ++++++- docs/plugins/development/model-features.md | 64 --- docs/plugins/development/models.md | 107 +++++ docs/plugins/development/rest-api.md | 46 ++ docs/plugins/development/views.md | 254 +++++++++++ mkdocs.yml | 9 +- 10 files changed, 583 insertions(+), 598 deletions(-) delete mode 100644 docs/media/plugins/plugin_admin_ui.png delete mode 100644 docs/plugins/development.md create mode 100644 docs/plugins/development/background-tasks.md delete mode 100644 docs/plugins/development/generic-views.md delete mode 100644 docs/plugins/development/model-features.md create mode 100644 docs/plugins/development/models.md create mode 100644 docs/plugins/development/rest-api.md create mode 100644 docs/plugins/development/views.md diff --git a/docs/media/plugins/plugin_admin_ui.png b/docs/media/plugins/plugin_admin_ui.png deleted file mode 100644 index 44802c5fca3eb41f7f84cfe50f25de6ca0cf55e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23831 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 diff --git a/docs/plugins/development.md b/docs/plugins/development.md deleted file mode 100644 index d488cad6b..000000000 --- a/docs/plugins/development.md +++ /dev/null @@ -1,433 +0,0 @@ -# Plugin Development - -!!! info "Help Improve the NetBox Plugins Framework!" - We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338). - -This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. - -Plugins can do a lot, including: - -* Create Django models to store data in the database -* Provide their own "pages" (views) in the web user interface -* Inject template content and navigation links -* Establish their own REST API endpoints -* Add custom request/response middleware - -However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. - -!!! warning - While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases. - -## Initial Setup - -### Plugin Structure - -Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: - -```no-highlight -project-name/ - - plugin_name/ - - templates/ - - plugin_name/ - - *.html - - __init__.py - - middleware.py - - navigation.py - - signals.py - - template_content.py - - urls.py - - views.py - - README - - setup.py -``` - -The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: - -* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. -* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. -* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens). - -The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. - -### Create setup.py - -`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: - -```python -from setuptools import find_packages, setup - -setup( - name='netbox-animal-sounds', - version='0.1', - description='An example NetBox plugin', - url='https://github.com/netbox-community/netbox-animal-sounds', - author='Jeremy Stretch', - license='Apache 2.0', - install_requires=[], - packages=find_packages(), - include_package_data=True, - zip_safe=False, -) -``` - -Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). - -!!! note - `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) - -### Define a PluginConfig - -The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: - -```python -from extras.plugins import PluginConfig - -class AnimalSoundsConfig(PluginConfig): - name = 'netbox_animal_sounds' - verbose_name = 'Animal Sounds' - description = 'An example plugin for development purposes' - version = '0.1' - author = 'Jeremy Stretch' - author_email = 'author@example.com' - base_url = 'animal-sounds' - required_settings = [] - default_settings = { - 'loud': False - } - -config = AnimalSoundsConfig -``` - -NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. - -#### PluginConfig Attributes - -| Name | Description | -| ---- |---------------------------------------------------------------------------------------------------------------| -| `name` | Raw plugin name; same as the plugin's source directory | -| `verbose_name` | Human-friendly name for the plugin | -| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | -| `description` | Brief description of the plugin's purpose | -| `author` | Name of plugin's author | -| `author_email` | Author's public email address | -| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | -| `required_settings` | A list of any configuration parameters that **must** be defined by the user | -| `default_settings` | A dictionary of configuration parameters and their default values | -| `min_version` | Minimum version of NetBox with which the plugin is compatible | -| `max_version` | Maximum version of NetBox with which the plugin is compatible | -| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | -| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | -| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | -| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | - -All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. - -### Create a Virtual Environment - -It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.) - -```shell -python3 -m venv /path/to/my/venv -``` - -You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) - -```shell -cd $VENV/lib/python3.8/site-packages/ -echo /opt/netbox/netbox > netbox.pth -``` - -### Install the Plugin for Development - -To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): - -```no-highlight -$ python setup.py develop -``` - -## Database Models - -If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. - -Below is an example `models.py` file containing a model with two character fields: - -```python -from django.db import models - -class Animal(models.Model): - name = models.CharField(max_length=50) - sound = models.CharField(max_length=50) - - def __str__(self): - return self.name -``` - -Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. - -!!! note - A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory. - -```no-highlight -$ ./manage.py makemigrations netbox_animal_sounds -Migrations for 'netbox_animal_sounds': - /home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py - - Create model Animal -``` - -Next, we can apply the migration to the database with the `migrate` command: - -```no-highlight -$ ./manage.py migrate netbox_animal_sounds -Operations to perform: - Apply all migrations: netbox_animal_sounds -Running migrations: - Applying netbox_animal_sounds.0001_initial... OK -``` - -For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). - -### Using the Django Admin Interface - -Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below: - -```python -from django.contrib import admin -from .models import Animal - -@admin.register(Animal) -class AnimalAdmin(admin.ModelAdmin): - list_display = ('name', 'sound') -``` - -This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view. - -![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png) - -## Views - -If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: - -```python -from django.shortcuts import render -from django.views.generic import View -from .models import Animal - -class RandomAnimalView(View): - """ - Display a randomly-selected animal. - """ - def get(self, request): - animal = Animal.objects.order_by('?').first() - return render(request, 'netbox_animal_sounds/animal.html', { - 'animal': animal, - }) -``` - -This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below. - -### Extending the Base Template - -NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks: - -* `title` - The page title -* `header` - The upper portion of the page -* `content` - The main page body -* `javascript` - A section at the end of the page for including Javascript code - -For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). - -```jinja2 -{% extends 'base/layout.html' %} - -{% block content %} - {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} -

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

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

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

    + {% endwith %} +{% endblock %} + +``` + +The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block. + +!!! note + Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of. + +### Extending Core Views + +Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: + +* `left_page()` - Inject content on the left side of the page +* `right_page()` - Inject content on the right side of the page +* `full_width_page()` - Inject content across the entire bottom of the page +* `buttons()` - Add buttons to the top of the page + +Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. + +When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: + +* `object` - The object being viewed +* `request` - The current request +* `settings` - Global NetBox settings +* `config` - Plugin-specific configuration parameters + +For example, accessing `{{ request.user }}` within a template will return the current user. + +Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. + +```python +from extras.plugins import PluginTemplateExtension +from .models import Animal + +class SiteAnimalCount(PluginTemplateExtension): + model = 'dcim.site' + + def right_page(self): + return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ + 'animal_count': Animal.objects.count(), + }) + +template_extensions = [SiteAnimalCount] +``` + +## Navigation Menu Items + +To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. + +```python +from extras.plugins import PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_animal_sounds:random_animal', + link_text='Random sound', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) + ), +) +``` + +A `PluginMenuItem` has the following attributes: + +* `link` - The name of the URL path to which this menu item links +* `link_text` - The text presented to the user +* `permissions` - A list of permissions required to display this link (optional) +* `buttons` - An iterable of PluginMenuButton instances to display (optional) + +A `PluginMenuButton` has the following attributes: + +* `link` - The name of the URL path to which this button links +* `title` - The tooltip text (displayed when the mouse hovers over the button) +* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/)) +* `color` - One of the choices provided by `ButtonColorChoices` (optional) +* `permissions` - A list of permissions required to display this button (optional) + +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. + +## Object Views Reference + +Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object. + +::: netbox.views.generic.base.BaseObjectView + rendering: + show_source: false + +::: netbox.views.generic.ObjectView + selection: + members: + - get_object + - get_template_name + rendering: + show_source: false + +::: netbox.views.generic.ObjectEditView + selection: + members: + - get_object + - alter_object + rendering: + show_source: false + +::: netbox.views.generic.ObjectDeleteView + selection: + members: + - get_object + rendering: + show_source: false + +## Multi-Object Views Reference + +Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects. + +::: netbox.views.generic.base.BaseMultiObjectView + rendering: + show_source: false + +::: netbox.views.generic.ObjectListView + selection: + members: + - get_table + - export_table + - export_template + rendering: + show_source: false + +::: netbox.views.generic.BulkImportView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkEditView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkDeleteView + selection: + members: + - get_form + rendering: + show_source: false diff --git a/mkdocs.yml b/mkdocs.yml index dbd31cb50..148e083d2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -101,10 +101,11 @@ nav: - Plugins: - Using Plugins: 'plugins/index.md' - Developing Plugins: - - Introduction: 'plugins/development/index.md' - - Model Features: 'plugins/development/model-features.md' - - Generic Views: 'plugins/development/generic-views.md' - - Developing Plugins (Old): 'plugins/development.md' + - Getting Started: 'plugins/development/index.md' + - Database Models: 'plugins/development/models.md' + - Views: 'plugins/development/views.md' + - REST API: 'plugins/development/rest-api.md' + - Background Tasks: 'plugins/development/background-tasks.md' - Administration: - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' From acc9ca7d7df98b147b34be8ccfc3fcac6f8fbb3c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 16:11:49 -0500 Subject: [PATCH 115/271] Move TagFilter to PrimaryFilterSet --- netbox/circuits/filtersets.py | 5 ----- netbox/dcim/filtersets.py | 22 ---------------------- netbox/ipam/filtersets.py | 14 -------------- netbox/netbox/filtersets.py | 5 +++++ netbox/tenancy/filtersets.py | 6 ------ netbox/virtualization/filtersets.py | 6 ------ netbox/wireless/filtersets.py | 4 ---- 7 files changed, 5 insertions(+), 57 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 0a90116bd..998a7bb6d 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,7 +3,6 @@ from django.db.models import Q from dcim.filtersets import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup -from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter @@ -61,7 +60,6 @@ class ProviderFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Provider @@ -94,7 +92,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Provider (slug)', ) - tag = TagFilter() class Meta: model = ProviderNetwork @@ -112,7 +109,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): class CircuitTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = CircuitType @@ -190,7 +186,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Circuit diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4dfb080bc..a7402fa5f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,7 +1,6 @@ import django_filters from django.contrib.auth.models import User -from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet from ipam.models import ASN, VRF from netbox.filtersets import ( @@ -79,7 +78,6 @@ class RegionFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) - tag = TagFilter() class Meta: model = Region @@ -97,7 +95,6 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) - tag = TagFilter() class Meta: model = SiteGroup @@ -148,7 +145,6 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): queryset=ASN.objects.all(), label='AS (ID)', ) - tag = TagFilter() class Meta: model = Site @@ -225,7 +221,6 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): to_field_name='slug', label='Location (slug)', ) - tag = TagFilter() class Meta: model = Location @@ -241,7 +236,6 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RackRole @@ -325,7 +319,6 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): serial = django_filters.CharFilter( lookup_expr='iexact' ) - tag = TagFilter() class Meta: model = Rack @@ -389,7 +382,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='username', label='User (name)', ) - tag = TagFilter() class Meta: model = RackReservation @@ -407,7 +399,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = Manufacturer @@ -461,7 +452,6 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = DeviceType @@ -546,7 +536,6 @@ class ModuleTypeFilterSet(PrimaryModelFilterSet): method='_pass_through_ports', label='Has pass-through ports', ) - tag = TagFilter() class Meta: model = ModuleType @@ -732,7 +721,6 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo class DeviceRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = DeviceRole @@ -751,7 +739,6 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) - tag = TagFilter() class Meta: model = Platform @@ -916,7 +903,6 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = Device @@ -990,7 +976,6 @@ class ModuleFilterSet(PrimaryModelFilterSet): queryset=Device.objects.all(), label='Device (ID)', ) - tag = TagFilter() class Meta: model = Module @@ -1080,7 +1065,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Virtual Chassis', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -1202,7 +1186,6 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() - tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1377,7 +1360,6 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = InventoryItemRole @@ -1447,7 +1429,6 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Tenant (slug)', ) - tag = TagFilter() class Meta: model = VirtualChassis @@ -1505,7 +1486,6 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): method='filter_device', field_name='device__site__slug' ) - tag = TagFilter() class Meta: model = Cable @@ -1571,7 +1551,6 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): lookup_expr='in', label='Location (ID)', ) - tag = TagFilter() class Meta: model = PowerPanel @@ -1641,7 +1620,6 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE choices=PowerFeedStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = PowerFeed diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 52e4499c7..aaba09bc6 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -6,7 +6,6 @@ from django.db.models import Q from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup -from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( @@ -63,7 +62,6 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='name', label='Export target (name)', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -106,7 +104,6 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='rd', label='Export VRF (RD)', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -122,7 +119,6 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RIRFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RIR @@ -152,7 +148,6 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='RIR (slug)', ) - tag = TagFilter() class Meta: model = Aggregate @@ -218,7 +213,6 @@ class RoleFilterSet(OrganizationalModelFilterSet): method='search', label='Search', ) - tag = TagFilter() class Meta: model = Role @@ -347,7 +341,6 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): choices=PrefixStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = Prefix @@ -453,7 +446,6 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): choices=IPRangeStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = IPRange @@ -578,7 +570,6 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): role = django_filters.MultipleChoiceFilter( choices=IPAddressRoleChoices ) - tag = TagFilter() class Meta: model = IPAddress @@ -664,7 +655,6 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): queryset=IPAddress.objects.all(), method='filter_related_ip' ) - tag = TagFilter() class Meta: model = FHRPGroup @@ -737,7 +727,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): cluster = django_filters.NumberFilter( method='filter_scope' ) - tag = TagFilter() class Meta: model = VLANGroup @@ -832,7 +821,6 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): queryset=VirtualMachine.objects.all(), method='get_for_virtualmachine' ) - tag = TagFilter() class Meta: model = VLAN @@ -864,7 +852,6 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet): field_name='ports', lookup_expr='contains' ) - tag = TagFilter() class Meta: model = ServiceTemplate @@ -906,7 +893,6 @@ class ServiceFilterSet(PrimaryModelFilterSet): field_name='ports', lookup_expr='contains' ) - tag = TagFilter() class Meta: model = Service diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index f42ab064b..3ddf252c7 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -120,6 +120,10 @@ class BaseFilterSet(django_filters.FilterSet): def get_additional_lookups(cls, existing_filter_name, existing_filter): new_filters = {} + # Skip on abstract models + if not cls._meta.model: + return {} + # Skip nonstandard lookup expressions if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: return {} @@ -214,6 +218,7 @@ class ChangeLoggedModelFilterSet(BaseFilterSet): class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): + tag = TagFilter() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index c8af89143..36f625507 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -1,7 +1,6 @@ import django_filters from django.db.models import Q -from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -33,7 +32,6 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) - tag = TagFilter() class Meta: model = TenantGroup @@ -58,7 +56,6 @@ class TenantFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) - tag = TagFilter() class Meta: model = Tenant @@ -119,7 +116,6 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) - tag = TagFilter() class Meta: model = ContactGroup @@ -127,7 +123,6 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): class ContactRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ContactRole @@ -152,7 +147,6 @@ class ContactFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) - tag = TagFilter() class Meta: model = Contact diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ed2775de2..0fe433bbc 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,7 +2,6 @@ import django_filters from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet @@ -20,7 +19,6 @@ __all__ = ( class ClusterTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ClusterType @@ -28,7 +26,6 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): class ClusterGroupFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ClusterGroup @@ -96,7 +93,6 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Cluster type (slug)', ) - tag = TagFilter() class Meta: model = Cluster @@ -217,7 +213,6 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf method='_has_primary_ip', label='Has a primary IP', ) - tag = TagFilter() class Meta: model = VirtualMachine @@ -278,7 +273,6 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet): mac_address = MultiValueMACAddressFilter( label='MAC address', ) - tag = TagFilter() class Meta: model = VMInterface diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 3fb173b1b..b95c18c9d 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,7 +2,6 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices -from extras.filters import TagFilter from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter @@ -25,7 +24,6 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): queryset=WirelessLANGroup.objects.all(), to_field_name='slug' ) - tag = TagFilter() class Meta: model = WirelessLANGroup @@ -57,7 +55,6 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): auth_cipher = django_filters.MultipleChoiceFilter( choices=WirelessAuthCipherChoices ) - tag = TagFilter() class Meta: model = WirelessLAN @@ -89,7 +86,6 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): auth_cipher = django_filters.MultipleChoiceFilter( choices=WirelessAuthCipherChoices ) - tag = TagFilter() class Meta: model = WirelessLink From 28de9b89132e063597e59e6c518adc260ab5c306 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 16:18:07 -0500 Subject: [PATCH 116/271] Refactor ChangeLoggedModelFilterSet --- netbox/netbox/filtersets.py | 48 +++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 3ddf252c7..a109b2c70 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -23,6 +23,31 @@ __all__ = ( ) +# +# Mixins +# + +class ChangeLoggedModelMixin: + created = django_filters.DateFilter() + created__gte = django_filters.DateFilter( + field_name='created', + lookup_expr='gte' + ) + created__lte = django_filters.DateFilter( + field_name='created', + lookup_expr='lte' + ) + last_updated = django_filters.DateTimeFilter() + last_updated__gte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='gte' + ) + last_updated__lte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='lte' + ) + + # # FilterSets # @@ -196,28 +221,11 @@ class BaseFilterSet(django_filters.FilterSet): return filters -class ChangeLoggedModelFilterSet(BaseFilterSet): - created = django_filters.DateFilter() - created__gte = django_filters.DateFilter( - field_name='created', - lookup_expr='gte' - ) - created__lte = django_filters.DateFilter( - field_name='created', - lookup_expr='lte' - ) - last_updated = django_filters.DateTimeFilter() - last_updated__gte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='gte' - ) - last_updated__lte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='lte' - ) +class ChangeLoggedModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): + pass -class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): +class PrimaryModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): tag = TagFilter() def __init__(self, *args, **kwargs): From e4abbfb2c6064d03091f3890a30343cfb788d8ea Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 25 Jan 2022 17:37:06 -0500 Subject: [PATCH 117/271] Closes #8454: Set DEFAULT_AUTO_FIELD to BigAutoField --- docs/release-notes/version-3.2.md | 1 + .../migrations/0033_gfk_bigidfield.py | 18 -- .../migrations/0033_standardize_id_fields.py | 44 +++ netbox/dcim/migrations/0151_gfk_bigidfield.py | 73 ----- .../migrations/0151_standardize_id_fields.py | 274 ++++++++++++++++++ netbox/dcim/models/cables.py | 4 +- .../extras/migrations/0071_gfk_bigidfield.py | 33 --- .../migrations/0071_standardize_id_fields.py | 94 ++++++ netbox/extras/models/change_logging.py | 3 +- netbox/extras/models/models.py | 4 +- netbox/extras/models/tags.py | 4 +- netbox/ipam/migrations/0056_gfk_bigidfield.py | 23 -- .../migrations/0056_standardize_id_fields.py | 99 +++++++ netbox/netbox/models/__init__.py | 11 +- netbox/netbox/settings.py | 2 +- .../tenancy/migrations/0005_gfk_bigidfield.py | 18 -- .../migrations/0005_standardize_id_fields.py | 49 ++++ .../migrations/0002_standardize_id_fields.py | 26 ++ netbox/users/models.py | 5 +- .../migrations/0027_standardize_id_fields.py | 36 +++ .../migrations/0002_standardize_id_fields.py | 26 ++ netbox/wireless/models.py | 2 +- 22 files changed, 665 insertions(+), 184 deletions(-) delete mode 100644 netbox/circuits/migrations/0033_gfk_bigidfield.py create mode 100644 netbox/circuits/migrations/0033_standardize_id_fields.py delete mode 100644 netbox/dcim/migrations/0151_gfk_bigidfield.py create mode 100644 netbox/dcim/migrations/0151_standardize_id_fields.py delete mode 100644 netbox/extras/migrations/0071_gfk_bigidfield.py create mode 100644 netbox/extras/migrations/0071_standardize_id_fields.py delete mode 100644 netbox/ipam/migrations/0056_gfk_bigidfield.py create mode 100644 netbox/ipam/migrations/0056_standardize_id_fields.py delete mode 100644 netbox/tenancy/migrations/0005_gfk_bigidfield.py create mode 100644 netbox/tenancy/migrations/0005_standardize_id_fields.py create mode 100644 netbox/users/migrations/0002_standardize_id_fields.py create mode 100644 netbox/virtualization/migrations/0027_standardize_id_fields.py create mode 100644 netbox/wireless/migrations/0002_standardize_id_fields.py diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index c35806c04..789003cca 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -81,6 +81,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#7743](https://github.com/netbox-community/netbox/issues/7743) - Remove legacy ASN field from site model * [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model * [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs +* [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys ### REST API Changes diff --git a/netbox/circuits/migrations/0033_gfk_bigidfield.py b/netbox/circuits/migrations/0033_gfk_bigidfield.py deleted file mode 100644 index 970617a88..000000000 --- a/netbox/circuits/migrations/0033_gfk_bigidfield.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0032_provider_service_id'), - ] - - operations = [ - migrations.AlterField( - model_name='circuittermination', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/circuits/migrations/0033_standardize_id_fields.py b/netbox/circuits/migrations/0033_standardize_id_fields.py new file mode 100644 index 000000000..475fc2527 --- /dev/null +++ b/netbox/circuits/migrations/0033_standardize_id_fields.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0032_provider_service_id'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='providernetwork', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='circuittermination', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0151_gfk_bigidfield.py b/netbox/dcim/migrations/0151_gfk_bigidfield.py deleted file mode 100644 index 733e6ecd5..000000000 --- a/netbox/dcim/migrations/0151_gfk_bigidfield.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0150_interface_speed_duplex'), - ] - - operations = [ - migrations.AlterField( - model_name='cable', - name='termination_a_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='cable', - name='termination_b_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='cablepath', - name='destination_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='cablepath', - name='origin_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='consoleport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='consoleserverport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='frontport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='interface', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='powerfeed', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='poweroutlet', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='powerport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='rearport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0151_standardize_id_fields.py b/netbox/dcim/migrations/0151_standardize_id_fields.py new file mode 100644 index 000000000..76fea859b --- /dev/null +++ b/netbox/dcim/migrations/0151_standardize_id_fields.py @@ -0,0 +1,274 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0150_interface_speed_duplex'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemtemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='location', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='module', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='moduletype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sitegroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='cable', + name='termination_a_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cable', + name='termination_b_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cablepath', + name='destination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cablepath', + name='origin_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='consoleport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e3cc20177..f1d4d7043 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from netbox.models import BigIDModel, PrimaryModel +from netbox.models import PrimaryModel from utilities.fields import ColorField from utilities.utils import to_meters from .devices import Device @@ -298,7 +298,7 @@ class Cable(PrimaryModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(BigIDModel): +class CablePath(models.Model): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do diff --git a/netbox/extras/migrations/0071_gfk_bigidfield.py b/netbox/extras/migrations/0071_gfk_bigidfield.py deleted file mode 100644 index 64ce3c471..000000000 --- a/netbox/extras/migrations/0071_gfk_bigidfield.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0070_customlink_enabled'), - ] - - operations = [ - migrations.AlterField( - model_name='imageattachment', - name='object_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='journalentry', - name='assigned_object_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='objectchange', - name='changed_object_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='objectchange', - name='related_object_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/extras/migrations/0071_standardize_id_fields.py b/netbox/extras/migrations/0071_standardize_id_fields.py new file mode 100644 index 000000000..fa2b132bf --- /dev/null +++ b/netbox/extras/migrations/0071_standardize_id_fields.py @@ -0,0 +1,94 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0070_customlink_enabled'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='configcontext', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='configrevision', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customfield', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customlink', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='exporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='imageattachment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='jobresult', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='journalentry', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='objectchange', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tag', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='taggeditem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='webhook', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='imageattachment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='journalentry', + name='assigned_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='changed_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='related_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 4e703833a..8444260c8 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -5,11 +5,10 @@ from django.db import models from django.urls import reverse from extras.choices import * -from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet -class ObjectChange(BigIDModel): +class ObjectChange(models.Model): """ Record a change to an object and the user account associated with that change. A change record may optionally indicate an object related to the one being changed. For example, a change to an interface may also indicate the diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 143bc7d9b..1ea4a01d4 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -18,7 +18,7 @@ from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet from extras.utils import FeatureQuery, image_upload -from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models import ChangeLoggedModel from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -467,7 +467,7 @@ class JournalEntry(WebhooksMixin, ChangeLoggedModel): return JournalEntryKindChoices.colors.get(self.kind) -class JobResult(BigIDModel): +class JobResult(models.Model): """ This model stores the results from running a user-defined report. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index df8446b9c..a4b3f080d 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -3,7 +3,7 @@ from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models import ChangeLoggedModel from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -36,7 +36,7 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): return slug -class TaggedItem(BigIDModel, GenericTaggedItemBase): +class TaggedItem(GenericTaggedItemBase): tag = models.ForeignKey( to=Tag, related_name="%(app_label)s_%(class)s_items", diff --git a/netbox/ipam/migrations/0056_gfk_bigidfield.py b/netbox/ipam/migrations/0056_gfk_bigidfield.py deleted file mode 100644 index f40f65271..000000000 --- a/netbox/ipam/migrations/0056_gfk_bigidfield.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0055_servicetemplate'), - ] - - operations = [ - migrations.AlterField( - model_name='fhrpgroupassignment', - name='interface_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='ipaddress', - name='assigned_object_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/ipam/migrations/0056_standardize_id_fields.py b/netbox/ipam/migrations/0056_standardize_id_fields.py new file mode 100644 index 000000000..cb7564450 --- /dev/null +++ b/netbox/ipam/migrations/0056_standardize_id_fields.py @@ -0,0 +1,99 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0055_servicetemplate'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='aggregate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='asn', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='fhrpgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='fhrpgroupassignment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='iprange', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='prefix', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rir', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='routetarget', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='service', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='servicetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlan', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlangroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vrf', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='fhrpgroupassignment', + name='interface_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 2db2e2602..3631cf7f4 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -7,7 +7,6 @@ from utilities.querysets import RestrictedQuerySet from netbox.models.features import * __all__ = ( - 'BigIDModel', 'ChangeLoggedModel', 'NestedGroupModel', 'OrganizationalModel', @@ -26,7 +25,7 @@ class BaseModel( ExportTemplatesMixin, JournalingMixin, TagsMixin, - WebhooksMixin, + WebhooksMixin ): class Meta: abstract = True @@ -44,7 +43,7 @@ class BigIDModel(models.Model): abstract = True -class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): +class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): """ Base model for all objects which support change logging. """ @@ -54,7 +53,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel): +class PrimaryModel(BaseModel, ChangeLoggingMixin, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -64,7 +63,7 @@ class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel): abstract = True -class NestedGroupModel(BaseModel, ChangeLoggingMixin, BigIDModel, MPTTModel): +class NestedGroupModel(BaseModel, ChangeLoggingMixin, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -106,7 +105,7 @@ class NestedGroupModel(BaseModel, ChangeLoggingMixin, BigIDModel, MPTTModel): }) -class OrganizationalModel(BaseModel, ChangeLoggingMixin, BigIDModel): +class OrganizationalModel(BaseModel, ChangeLoggingMixin, models.Model): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5808602a2..2c33ec862 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -406,7 +406,7 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. diff --git a/netbox/tenancy/migrations/0005_gfk_bigidfield.py b/netbox/tenancy/migrations/0005_gfk_bigidfield.py deleted file mode 100644 index 12bbde295..000000000 --- a/netbox/tenancy/migrations/0005_gfk_bigidfield.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-24 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tenancy', '0004_extend_tag_support'), - ] - - operations = [ - migrations.AlterField( - model_name='contactassignment', - name='object_id', - field=models.PositiveBigIntegerField(), - ), - ] diff --git a/netbox/tenancy/migrations/0005_standardize_id_fields.py b/netbox/tenancy/migrations/0005_standardize_id_fields.py new file mode 100644 index 000000000..514478f17 --- /dev/null +++ b/netbox/tenancy/migrations/0005_standardize_id_fields.py @@ -0,0 +1,49 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='contact', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactassignment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenant', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenantgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='contactassignment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + ] diff --git a/netbox/users/migrations/0002_standardize_id_fields.py b/netbox/users/migrations/0002_standardize_id_fields.py new file mode 100644 index 000000000..60191d916 --- /dev/null +++ b/netbox/users/migrations/0002_standardize_id_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_squashed_0011'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='token', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userconfig', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 0ce91363b..722ec5ba6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -11,7 +11,6 @@ from django.dispatch import receiver from django.utils import timezone from netbox.config import get_config -from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * @@ -187,7 +186,7 @@ def create_userconfig(instance, created, **kwargs): # REST API # -class Token(BigIDModel): +class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. @@ -246,7 +245,7 @@ class Token(BigIDModel): # Permissions # -class ObjectPermission(BigIDModel): +class ObjectPermission(models.Model): """ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects identified by ORM query parameters. diff --git a/netbox/virtualization/migrations/0027_standardize_id_fields.py b/netbox/virtualization/migrations/0027_standardize_id_fields.py new file mode 100644 index 000000000..01d7e8af1 --- /dev/null +++ b/netbox/virtualization/migrations/0027_standardize_id_fields.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0026_vminterface_bridge'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustergroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustertype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualmachine', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vminterface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/wireless/migrations/0002_standardize_id_fields.py b/netbox/wireless/migrations/0002_standardize_id_fields.py new file mode 100644 index 000000000..9e0b202c2 --- /dev/null +++ b/netbox/wireless/migrations/0002_standardize_id_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslan', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='wirelesslangroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='wirelesslink', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 843462ec6..621024d79 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,7 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, PrimaryModel from .choices import * from .constants import * From b797b08bcfe53fefc3add75f54cb54c2f4e760ea Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 09:02:04 -0500 Subject: [PATCH 118/271] Remove BigIDModel --- .../extras/migrations/0071_standardize_id_fields.py | 5 ----- netbox/extras/models/tags.py | 3 +++ netbox/netbox/models/__init__.py | 12 ------------ 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/netbox/extras/migrations/0071_standardize_id_fields.py b/netbox/extras/migrations/0071_standardize_id_fields.py index fa2b132bf..63e3051d8 100644 --- a/netbox/extras/migrations/0071_standardize_id_fields.py +++ b/netbox/extras/migrations/0071_standardize_id_fields.py @@ -54,11 +54,6 @@ class Migration(migrations.Migration): name='id', field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), ), - migrations.AlterField( - model_name='tag', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), migrations.AlterField( model_name='taggeditem', name='id', diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index a4b3f080d..a4e4049d7 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -14,6 +14,9 @@ from utilities.fields import ColorField # class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): + id = models.BigAutoField( + primary_key=True + ) color = ColorField( default=ColorChoices.COLOR_GREY ) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 3631cf7f4..638d27c1b 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -31,18 +31,6 @@ class BaseModel( abstract = True -class BigIDModel(models.Model): - """ - Abstract base model for all data objects. Ensures the use of a 64-bit PK. - """ - id = models.BigAutoField( - primary_key=True - ) - - class Meta: - abstract = True - - class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): """ Base model for all objects which support change logging. From eb00e202693dcccb836b3dbafa86ec254afc5d45 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 09:03:30 -0500 Subject: [PATCH 119/271] Revert "Refactor ChangeLoggedModelFilterSet" This reverts commit 28de9b89132e063597e59e6c518adc260ab5c306. --- netbox/netbox/filtersets.py | 48 ++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index a109b2c70..3ddf252c7 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -23,31 +23,6 @@ __all__ = ( ) -# -# Mixins -# - -class ChangeLoggedModelMixin: - created = django_filters.DateFilter() - created__gte = django_filters.DateFilter( - field_name='created', - lookup_expr='gte' - ) - created__lte = django_filters.DateFilter( - field_name='created', - lookup_expr='lte' - ) - last_updated = django_filters.DateTimeFilter() - last_updated__gte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='gte' - ) - last_updated__lte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='lte' - ) - - # # FilterSets # @@ -221,11 +196,28 @@ class BaseFilterSet(django_filters.FilterSet): return filters -class ChangeLoggedModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): - pass +class ChangeLoggedModelFilterSet(BaseFilterSet): + created = django_filters.DateFilter() + created__gte = django_filters.DateFilter( + field_name='created', + lookup_expr='gte' + ) + created__lte = django_filters.DateFilter( + field_name='created', + lookup_expr='lte' + ) + last_updated = django_filters.DateTimeFilter() + last_updated__gte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='gte' + ) + last_updated__lte = django_filters.DateTimeFilter( + field_name='last_updated', + lookup_expr='lte' + ) -class PrimaryModelFilterSet(ChangeLoggedModelMixin, BaseFilterSet): +class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): tag = TagFilter() def __init__(self, *args, **kwargs): From b67859832afa52742defa0a5bd60f9be1ddbe8e4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 20:25:23 -0500 Subject: [PATCH 120/271] Refactor to_objectchange() --- netbox/circuits/models/circuits.py | 10 ++---- .../dcim/models/device_component_templates.py | 32 +++++++------------ netbox/dcim/models/device_components.py | 10 ++---- netbox/extras/models/models.py | 4 ++- netbox/ipam/models/ip.py | 5 +-- netbox/netbox/models/features.py | 3 +- netbox/virtualization/models.py | 5 +-- 7 files changed, 27 insertions(+), 42 deletions(-) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index e697caa0a..faea380f0 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -209,13 +209,9 @@ class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): - # Annotate the parent Circuit - try: - circuit = self.circuit - except Circuit.DoesNotExist: - # Parent circuit has been deleted - circuit = None - return super().to_objectchange(action, related_object=circuit) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.circuit + return objectchange @property def parent_object(self): diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 72ac9df40..0538704d2 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -70,14 +70,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): """ raise NotImplementedError() - def to_objectchange(self, action, related_object=None): - # Annotate the parent DeviceType - try: - device_type = self.device_type - except ObjectDoesNotExist: - # The parent DeviceType has already been deleted - device_type = None - return super().to_objectchange(action, related_object=device_type) + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device_type + return objectchange class ModularComponentTemplateModel(ComponentTemplateModel): @@ -102,19 +98,13 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True - def to_objectchange(self, action, related_object=None): - # Annotate the parent DeviceType or ModuleType - try: - if getattr(self, 'device_type'): - return super().to_objectchange(action, related_object=self.device_type) - except ObjectDoesNotExist: - pass - try: - if getattr(self, 'module_type'): - return super().to_objectchange(action, related_object=self.module_type) - except ObjectDoesNotExist: - pass - return super().to_objectchange(action) + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + if self.device_type is not None: + objectchange.related_object = self.device_type + elif self.module_type is not None: + objectchange.related_object = self.module_type + return objectchange def clean(self): super().clean() diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9071dfe46..de22708ea 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -75,13 +75,9 @@ class ComponentModel(PrimaryModel): return self.name def to_objectchange(self, action): - # Annotate the parent Device - try: - device = self.device - except ObjectDoesNotExist: - # The parent Device has already been deleted - device = None - return super().to_objectchange(action, related_object=device) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device + return super().to_objectchange(action) @property def parent_object(self): diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 1ea4a01d4..afcb6556c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -418,7 +418,9 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel): return None def to_objectchange(self, action): - return super().to_objectchange(action, related_object=self.parent) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.parent + return objectchange class JournalEntry(WebhooksMixin, ChangeLoggedModel): diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 632d71034..b13899f7c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -904,8 +904,9 @@ class IPAddress(PrimaryModel): super().save(*args, **kwargs) def to_objectchange(self, action): - # Annotate the assigned object, if any - return super().to_objectchange(action, related_object=self.assigned_object) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.assigned_object + return objectchange @property def family(self): diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ce3980459..19b804b01 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -57,7 +57,7 @@ class ChangeLoggingMixin(models.Model): logger.debug(f"Taking a snapshot of {self}") self._prechange_snapshot = serialize_object(self) - def to_objectchange(self, action, related_object=None): + def to_objectchange(self, action): """ Return a new ObjectChange representing a change made to this object. This will typically be called automatically by ChangeLoggingMiddleware. @@ -65,7 +65,6 @@ class ChangeLoggingMixin(models.Model): from extras.models import ObjectChange objectchange = ObjectChange( changed_object=self, - related_object=related_object, object_repr=str(self)[:200], action=action ) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index d2f513f0b..790cdcdbf 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -441,8 +441,9 @@ class VMInterface(PrimaryModel, BaseInterface): }) def to_objectchange(self, action): - # Annotate the parent VirtualMachine - return super().to_objectchange(action, related_object=self.virtual_machine) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.virtual_machine + return objectchange @property def parent_object(self): From a795b95f7edcdc84a753e05d9907c1592b858a87 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 20:41:41 -0500 Subject: [PATCH 121/271] Closes #8451: Include ChangeLoggingMixin in BaseModel --- docs/plugins/development/models.md | 3 +++ netbox/netbox/models/__init__.py | 7 ++++--- netbox/netbox/models/features.py | 4 ---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index bf06faf08..b8b1e3122 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -47,6 +47,7 @@ For more background on schema migrations, see the [Django documentation](https:/ Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: +* Change logging * Custom fields * Custom links * Custom validation @@ -92,6 +93,8 @@ The example above will enable export templates and tags, but no other NetBox fea !!! note Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. +::: netbox.models.features.ChangeLoggingMixin + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 638d27c1b..bf120c8ac 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -19,6 +19,7 @@ __all__ = ( # class BaseModel( + ChangeLoggingMixin, CustomFieldsMixin, CustomLinksMixin, CustomValidationMixin, @@ -41,7 +42,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class PrimaryModel(BaseModel, ChangeLoggingMixin, models.Model): +class PrimaryModel(BaseModel, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -51,7 +52,7 @@ class PrimaryModel(BaseModel, ChangeLoggingMixin, models.Model): abstract = True -class NestedGroupModel(BaseModel, ChangeLoggingMixin, MPTTModel): +class NestedGroupModel(BaseModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -93,7 +94,7 @@ class NestedGroupModel(BaseModel, ChangeLoggingMixin, MPTTModel): }) -class OrganizationalModel(BaseModel, ChangeLoggingMixin, models.Model): +class OrganizationalModel(BaseModel, models.Model): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 19b804b01..24b9a4bff 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,5 +1,3 @@ -import logging - from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -53,8 +51,6 @@ class ChangeLoggingMixin(models.Model): """ Save a snapshot of the object's current state in preparation for modification. """ - logger = logging.getLogger('netbox') - logger.debug(f"Taking a snapshot of {self}") self._prechange_snapshot = serialize_object(self) def to_objectchange(self, action): From c5650bb2787c2fe1f55234904c6e0570f2eab17d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 26 Jan 2022 20:57:14 -0500 Subject: [PATCH 122/271] Rename PrimaryModel to NetBoxModel --- docs/development/adding-models.md | 2 +- docs/plugins/development/models.md | 7 ++++--- netbox/circuits/models/circuits.py | 4 ++-- netbox/circuits/models/providers.py | 6 +++--- netbox/dcim/models/cables.py | 4 ++-- netbox/dcim/models/device_components.py | 4 ++-- netbox/dcim/models/devices.py | 12 ++++++------ netbox/dcim/models/power.py | 6 +++--- netbox/dcim/models/racks.py | 6 +++--- netbox/dcim/models/sites.py | 4 ++-- netbox/ipam/models/fhrp.py | 4 ++-- netbox/ipam/models/ip.py | 12 ++++++------ netbox/ipam/models/services.py | 6 +++--- netbox/ipam/models/vlans.py | 4 ++-- netbox/ipam/models/vrfs.py | 6 +++--- netbox/netbox/models/__init__.py | 21 +++++++++++---------- netbox/tenancy/models/contacts.py | 4 ++-- netbox/tenancy/models/tenants.py | 4 ++-- netbox/virtualization/models.py | 8 ++++---- netbox/wireless/models.py | 6 +++--- 20 files changed, 66 insertions(+), 64 deletions(-) diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index d55afb2f2..f4d171f48 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -2,7 +2,7 @@ ## 1. Define the model class -Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. +Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be NetBoxModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. Each model should define, at a minimum: diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index b8b1e3122..225ac3d92 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -65,9 +65,10 @@ Simply subclass BaseModel when defining a model in your plugin: ```python # models.py -from netbox.models import BaseModel +from django.db import models +from netbox.models import NetBoxModel -class MyModel(BaseModel): +class MyModel(NetBoxModel): foo = models.CharField() ... ``` @@ -78,7 +79,7 @@ If you prefer instead to enable only a subset of these features for a plugin mod ```python # models.py -from django.db.models import models +from django.db import models from netbox.models.features import ExportTemplatesMixin, TagsMixin class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index faea380f0..0f3de91ed 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,7 +5,7 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel from netbox.models.features import WebhooksMixin __all__ = ( @@ -43,7 +43,7 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -class Circuit(PrimaryModel): +class Circuit(NetBoxModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 8fd52c587..9cf4bf5c1 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,7 +3,7 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel __all__ = ( 'ProviderNetwork', @@ -11,7 +11,7 @@ __all__ = ( ) -class Provider(PrimaryModel): +class Provider(NetBoxModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -70,7 +70,7 @@ class Provider(PrimaryModel): return reverse('circuits:provider', args=[self.pk]) -class ProviderNetwork(PrimaryModel): +class ProviderNetwork(NetBoxModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or unimportant to the user. diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index f1d4d7043..0d46d3c8f 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.fields import ColorField from utilities.utils import to_meters from .devices import Device @@ -28,7 +28,7 @@ __all__ = ( # Cables # -class Cable(PrimaryModel): +class Cable(NetBoxModel): """ A physical connection between two endpoints. """ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index de22708ea..a6887a768 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager @@ -39,7 +39,7 @@ __all__ = ( ) -class ComponentModel(PrimaryModel): +class ComponentModel(NetBoxModel): """ An abstract model inherited by any model which has a parent Device. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f94c9757d..37c900286 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -14,7 +14,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * @@ -68,7 +68,7 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -class DeviceType(PrimaryModel): +class DeviceType(NetBoxModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -350,7 +350,7 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(PrimaryModel): +class ModuleType(NetBoxModel): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a @@ -569,7 +569,7 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -class Device(PrimaryModel, ConfigContextModel): +class Device(NetBoxModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -1005,7 +1005,7 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') -class Module(PrimaryModel, ConfigContextModel): +class Module(NetBoxModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. @@ -1087,7 +1087,7 @@ class Module(PrimaryModel, ConfigContextModel): # Virtual chassis # -class VirtualChassis(PrimaryModel): +class VirtualChassis(NetBoxModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index fe7f69df9..bbbdda83c 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,7 +6,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -20,7 +20,7 @@ __all__ = ( # Power # -class PowerPanel(PrimaryModel): +class PowerPanel(NetBoxModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -66,7 +66,7 @@ class PowerPanel(PrimaryModel): ) -class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): +class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 1ebbbcba4..0fe84aa0c 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -14,7 +14,7 @@ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG from netbox.config import get_config -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string @@ -63,7 +63,7 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -class Rack(PrimaryModel): +class Rack(NetBoxModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -435,7 +435,7 @@ class Rack(PrimaryModel): return int(allocated_draw_total / available_power_total * 100) -class RackReservation(PrimaryModel): +class RackReservation(NetBoxModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 3756933ac..625422d6b 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,7 +7,7 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel from utilities.fields import NaturalOrderingField __all__ = ( @@ -194,7 +194,7 @@ class SiteGroup(NestedGroupModel): # Sites # -class Site(PrimaryModel): +class Site(NetBoxModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index f0e3c2a23..2a8d1bdcd 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NetBoxModel from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,7 +15,7 @@ __all__ = ( ) -class FHRPGroup(PrimaryModel): +class FHRPGroup(NetBoxModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) """ diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b13899f7c..1354c6e64 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,7 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -88,7 +88,7 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -class ASN(PrimaryModel): +class ASN(NetBoxModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have one or more ASNs assigned to it. @@ -147,7 +147,7 @@ class ASN(PrimaryModel): return self.asn -class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): +class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -280,7 +280,7 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -class Prefix(GetAvailablePrefixesMixin, PrimaryModel): +class Prefix(GetAvailablePrefixesMixin, NetBoxModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -557,7 +557,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -class IPRange(PrimaryModel): +class IPRange(NetBoxModel): """ A range of IP addresses, defined by start and end addresses. """ @@ -752,7 +752,7 @@ class IPRange(PrimaryModel): return int(float(child_count) / self.size * 100) -class IPAddress(PrimaryModel): +class IPAddress(NetBoxModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index bd8030a0a..70ad38197 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -6,7 +6,7 @@ from django.urls import reverse from ipam.choices import * from ipam.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.utils import array_to_string @@ -46,7 +46,7 @@ class ServiceBase(models.Model): return array_to_string(self.ports) -class ServiceTemplate(ServiceBase, PrimaryModel): +class ServiceTemplate(ServiceBase, NetBoxModel): """ A template for a Service to be applied to a device or virtual machine. """ @@ -62,7 +62,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel): return reverse('ipam:servicetemplate', args=[self.pk]) -class Service(ServiceBase, PrimaryModel): +class Service(ServiceBase, NetBoxModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index f73571ea9..7cd03ed55 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -9,7 +9,7 @@ from dcim.models import Interface from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from virtualization.models import VMInterface @@ -116,7 +116,7 @@ class VLANGroup(OrganizationalModel): return None -class VLAN(PrimaryModel): +class VLAN(NetBoxModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index f1b2d682f..fc34b5488 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -2,7 +2,7 @@ from django.db import models from django.urls import reverse from ipam.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel __all__ = ( @@ -11,7 +11,7 @@ __all__ = ( ) -class VRF(PrimaryModel): +class VRF(NetBoxModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF @@ -73,7 +73,7 @@ class VRF(PrimaryModel): return reverse('ipam:vrf', args=[self.pk]) -class RouteTarget(PrimaryModel): +class RouteTarget(NetBoxModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. """ diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index bf120c8ac..b3bfe06c0 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -10,15 +10,11 @@ __all__ = ( 'ChangeLoggedModel', 'NestedGroupModel', 'OrganizationalModel', - 'PrimaryModel', + 'NetBoxModel', ) -# -# Base model classes -# - -class BaseModel( +class NetBoxFeatureSet( ChangeLoggingMixin, CustomFieldsMixin, CustomLinksMixin, @@ -32,9 +28,14 @@ class BaseModel( abstract = True +# +# Base model classes +# + class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): """ - Base model for all objects which support change logging. + Base model for ancillary models; provides limited functionality for models which don't + support NetBox's full feature set. """ objects = RestrictedQuerySet.as_manager() @@ -42,7 +43,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class PrimaryModel(BaseModel, models.Model): +class NetBoxModel(NetBoxFeatureSet, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -52,7 +53,7 @@ class PrimaryModel(BaseModel, models.Model): abstract = True -class NestedGroupModel(BaseModel, MPTTModel): +class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -94,7 +95,7 @@ class NestedGroupModel(BaseModel, MPTTModel): }) -class OrganizationalModel(BaseModel, models.Model): +class OrganizationalModel(NetBoxFeatureSet, models.Model): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index cacd682cb..81dd99773 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, NetBoxModel from netbox.models.features import WebhooksMixin from tenancy.choices import * @@ -76,7 +76,7 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -class Contact(PrimaryModel): +class Contact(NetBoxModel): """ Contact information for a particular object(s) in NetBox. """ diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index 9952a700d..88d8d52f1 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -3,7 +3,7 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel __all__ = ( 'Tenant', @@ -43,7 +43,7 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -class Tenant(PrimaryModel): +class Tenant(NetBoxModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 790cdcdbf..dda1d0bee 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -8,7 +8,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -100,7 +100,7 @@ class ClusterGroup(OrganizationalModel): # Clusters # -class Cluster(PrimaryModel): +class Cluster(NetBoxModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -183,7 +183,7 @@ class Cluster(PrimaryModel): # Virtual machines # -class VirtualMachine(PrimaryModel, ConfigContextModel): +class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -345,7 +345,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # Interfaces # -class VMInterface(PrimaryModel, BaseInterface): +class VMInterface(NetBoxModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 621024d79..fc80e91df 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,7 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel from .choices import * from .constants import * @@ -79,7 +79,7 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): +class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -117,7 +117,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslan', args=[self.pk]) -class WirelessLink(WirelessAuthenticationBase, PrimaryModel): +class WirelessLink(WirelessAuthenticationBase, NetBoxModel): """ A point-to-point connection between two wireless Interfaces. """ From 083d1acb81a2028acbe5d20ef0993061c93fda1a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 09:24:20 -0500 Subject: [PATCH 123/271] Closes #8453: Rename PrimaryModelFilterSet to NetBoxModelFilterSet & expose for plugins --- docs/plugins/development/filtersets.md | 56 ++++++++++++++++++++++++++ docs/plugins/development/models.md | 2 +- mkdocs.yml | 1 + netbox/circuits/filtersets.py | 8 ++-- netbox/dcim/filtersets.py | 44 ++++++++++---------- netbox/ipam/filtersets.py | 22 +++++----- netbox/netbox/filtersets.py | 11 +++-- netbox/tenancy/filtersets.py | 6 +-- netbox/virtualization/filtersets.py | 8 ++-- netbox/wireless/filtersets.py | 6 +-- 10 files changed, 112 insertions(+), 52 deletions(-) create mode 100644 docs/plugins/development/filtersets.md diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md new file mode 100644 index 000000000..e2a98ed0b --- /dev/null +++ b/docs/plugins/development/filtersets.md @@ -0,0 +1,56 @@ +# Filter Sets + +Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. + +## FilterSet Classes + +To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below. + +```python +# filtersets.py +import django_filters +from netbox.filtersets import NetBoxModelFilterSet +from .models import MyModel + +class MyFilterSet(NetBoxModelFilterSet): + status = django_filters.MultipleChoiceFilter( + choices=( + ('foo', 'Foo'), + ('bar', 'Bar'), + ('baz', 'Baz'), + ), + null_value=None + ) + + class Meta: + model = MyModel + fields = ('some', 'other', 'fields') +``` + +## Declaring Filter Sets + +To utilize a filter set in the subclass of a generic view, such as `ObjectListView` or `BulkEditView`, set it as the `filterset` attribute on the view class: + +```python +# views.py +from netbox.views.generic import ObjectListView +from .filtersets import MyModelFitlerSet +from .models import MyModel + +class MyModelListView(ObjectListView): + queryset = MyModel.objects.all() + filterset = MyModelFitlerSet +``` + +To enable a filter on a REST API endpoint, set it as the `filterset_class` attribute on the API view: + +```python +# api/views.py +from myplugin import models, filtersets +from . import serializers + +class MyModelViewSet(...): + queryset = models.MyModel.objects.all() + serializer_class = serializers.MyModelSerializer + filterset_class = filtersets.MyModelFilterSet +``` diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 225ac3d92..521420b1b 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -45,7 +45,7 @@ For more background on schema migrations, see the [Django documentation](https:/ ## Enabling NetBox Features -Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including: +Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable numerous feature, including: * Change logging * Custom fields diff --git a/mkdocs.yml b/mkdocs.yml index 148e083d2..1a77cb195 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,7 @@ nav: - Getting Started: 'plugins/development/index.md' - Database Models: 'plugins/development/models.md' - Views: 'plugins/development/views.md' + - Filtersets: 'plugins/development/filtersets.md' - REST API: 'plugins/development/rest-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Administration: diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 998a7bb6d..40ac61e77 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.filtersets import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -18,7 +18,7 @@ __all__ = ( ) -class ProviderFilterSet(PrimaryModelFilterSet): +class ProviderFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -77,7 +77,7 @@ class ProviderFilterSet(PrimaryModelFilterSet): ) -class ProviderNetworkFilterSet(PrimaryModelFilterSet): +class ProviderNetworkFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -115,7 +115,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug'] -class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a7402fa5f..dda6de5b1 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from extras.filtersets import LocalConfigContextFilterSet from ipam.models import ASN, VRF from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, + BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant @@ -101,7 +101,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -242,7 +242,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -339,7 +339,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -405,7 +405,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class DeviceTypeFilterSet(PrimaryModelFilterSet): +class DeviceTypeFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -497,7 +497,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): return queryset.exclude(devicebaytemplates__isnull=value) -class ModuleTypeFilterSet(PrimaryModelFilterSet): +class ModuleTypeFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -745,7 +745,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] -class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): +class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -956,7 +956,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex return queryset.exclude(devicebays__isnull=value) -class ModuleFilterSet(PrimaryModelFilterSet): +class ModuleFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1096,7 +1096,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) -class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsolePortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -1107,7 +1107,7 @@ class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'description'] -class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -1118,7 +1118,7 @@ class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet fields = ['id', 'name', 'label', 'description'] -class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -1129,7 +1129,7 @@ class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -1144,7 +1144,7 @@ class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'feed_leg', 'description'] -class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1271,7 +1271,7 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT }.get(value, queryset.none()) -class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1282,7 +1282,7 @@ class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'type', 'color', 'description'] -class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class RearPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1293,21 +1293,21 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] -class ModuleBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class Meta: model = ModuleBay fields = ['id', 'name', 'label', 'description'] -class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'label', 'description'] -class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1366,7 +1366,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'color'] -class VirtualChassisFilterSet(PrimaryModelFilterSet): +class VirtualChassisFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1445,7 +1445,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() -class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): +class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1504,7 +1504,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset -class PowerPanelFilterSet(PrimaryModelFilterSet): +class PowerPanelFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1565,7 +1565,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index aaba09bc6..207b5e2dc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -6,7 +6,7 @@ from django.db.models import Q from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -35,7 +35,7 @@ __all__ = ( ) -class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -77,7 +77,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): fields = ['id', 'name', 'rd', 'enforce_unique'] -class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -125,7 +125,7 @@ class RIRFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'is_private', 'description'] -class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -219,7 +219,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug'] -class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -409,7 +409,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): +class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -475,7 +475,7 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset.none() -class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -640,7 +640,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.exclude(assigned_object_id__isnull=value) -class FHRPGroupFilterSet(PrimaryModelFilterSet): +class FHRPGroupFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -748,7 +748,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): ) -class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -843,7 +843,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.get_for_virtualmachine(value) -class ServiceTemplateFilterSet(PrimaryModelFilterSet): +class ServiceTemplateFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -864,7 +864,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class ServiceFilterSet(PrimaryModelFilterSet): +class ServiceFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 3ddf252c7..e36b9dd1d 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -18,8 +18,8 @@ from utilities import filters __all__ = ( 'BaseFilterSet', 'ChangeLoggedModelFilterSet', + 'NetBoxModelFilterSet', 'OrganizationalModelFilterSet', - 'PrimaryModelFilterSet', ) @@ -29,7 +29,7 @@ __all__ = ( class BaseFilterSet(django_filters.FilterSet): """ - A base FilterSet which provides common functionality to all NetBox FilterSets + A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class. """ FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) FILTER_DEFAULTS.update({ @@ -217,7 +217,10 @@ class ChangeLoggedModelFilterSet(BaseFilterSet): ) -class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): +class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): + """ + Provides additional filtering functionality (e.g. tags, custom fields) for core NetBox models. + """ tag = TagFilter() def __init__(self, *args, **kwargs): @@ -244,7 +247,7 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): self.filters.update(custom_field_filters) -class OrganizationalModelFilterSet(PrimaryModelFilterSet): +class OrganizationalModelFilterSet(NetBoxModelFilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields """ diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 36f625507..28087fecf 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -38,7 +38,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class TenantFilterSet(PrimaryModelFilterSet): +class TenantFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -129,7 +129,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug'] -class ContactFilterSet(PrimaryModelFilterSet): +class ContactFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 0fe433bbc..28b23e8a8 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * @@ -32,7 +32,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -107,7 +107,7 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): +class VirtualMachineFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -233,7 +233,7 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf return queryset.exclude(params) -class VMInterfaceFilterSet(PrimaryModelFilterSet): +class VMInterfaceFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index b95c18c9d..a88179acf 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from ipam.models import VLAN -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -30,7 +30,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class WirelessLANFilterSet(PrimaryModelFilterSet): +class WirelessLANFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -70,7 +70,7 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class WirelessLinkFilterSet(PrimaryModelFilterSet): +class WirelessLinkFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', From 4a1b4e04853d73eb3d7829744dd689343875c369 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 15:00:10 -0500 Subject: [PATCH 124/271] Closes #8469: Move BaseTable, columns to netbox core app --- netbox/circuits/tables.py | 31 +++-- netbox/circuits/views.py | 3 +- netbox/dcim/tables/__init__.py | 8 +- netbox/dcim/tables/cables.py | 12 +- netbox/dcim/tables/devices.py | 115 +++++++++--------- netbox/dcim/tables/devicetypes.py | 46 ++++--- netbox/dcim/tables/modules.py | 16 +-- netbox/dcim/tables/power.py | 18 +-- netbox/dcim/tables/racks.py | 31 +++-- netbox/dcim/tables/sites.py | 42 +++---- netbox/dcim/views.py | 2 +- netbox/extras/tables.py | 63 +++++----- netbox/extras/views.py | 2 +- netbox/ipam/tables/fhrp.py | 12 +- netbox/ipam/tables/ip.py | 66 +++++----- netbox/ipam/tables/services.py | 10 +- netbox/ipam/tables/vlans.py | 35 +++--- netbox/ipam/tables/vrfs.py | 18 +-- netbox/ipam/views.py | 2 +- .../{utilities => netbox}/tables/__init__.py | 10 -- .../{utilities => netbox}/tables/columns.py | 0 netbox/{utilities => netbox}/tables/tables.py | 2 +- netbox/netbox/views/generic/bulk_views.py | 2 +- netbox/netbox/views/generic/object_views.py | 2 +- netbox/tenancy/tables.py | 42 +++---- netbox/tenancy/views.py | 2 +- netbox/users/tests/test_preferences.py | 2 +- netbox/utilities/tables.py | 7 ++ netbox/utilities/tests/test_tables.py | 4 +- netbox/virtualization/tables.py | 39 +++--- netbox/virtualization/views.py | 2 +- netbox/wireless/tables.py | 22 ++-- netbox/wireless/views.py | 2 +- 33 files changed, 321 insertions(+), 349 deletions(-) rename netbox/{utilities => netbox}/tables/__init__.py (91%) rename netbox/{utilities => netbox}/tables/columns.py (100%) rename netbox/{utilities => netbox}/tables/tables.py (99%) create mode 100644 netbox/utilities/tables.py diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index b5fdc5440..0ffb8f03b 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,11 +1,10 @@ import django_tables2 as tables from django_tables2.utils import Accessor +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from .models import * - __all__ = ( 'CircuitTable', 'CircuitTypeTable', @@ -22,11 +21,11 @@ CIRCUITTERMINATION_LINK = """ {% endif %} """ + # # Table columns # - class CommitRateColumn(tables.TemplateColumn): """ Humanize the commit rate in the column view @@ -43,13 +42,13 @@ class CommitRateColumn(tables.TemplateColumn): def value(self, value): return str(value) if value else None + # # Providers # - class ProviderTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -57,8 +56,8 @@ class ProviderTable(BaseTable): accessor=Accessor('count_circuits'), verbose_name='Circuits' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:provider_list' ) @@ -76,15 +75,15 @@ class ProviderTable(BaseTable): # class ProviderNetworkTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) provider = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:providernetwork_list' ) @@ -101,11 +100,11 @@ class ProviderNetworkTable(BaseTable): # class CircuitTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='circuits:circuittype_list' ) circuit_count = tables.Column( @@ -125,7 +124,7 @@ class CircuitTypeTable(BaseTable): # class CircuitTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() cid = tables.Column( linkify=True, verbose_name='Circuit ID' @@ -133,7 +132,7 @@ class CircuitTable(BaseTable): provider = tables.Column( linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() termination_a = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, @@ -144,8 +143,8 @@ class CircuitTable(BaseTable): verbose_name='Side Z' ) commit_rate = CommitRateColumn() - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:circuit_list' ) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 97e985dcd..3229977be 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,10 +5,9 @@ from django.shortcuts import get_object_or_404, redirect, render from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables -from .choices import CircuitTerminationSideChoices from .models import * diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 993ae0518..7567762fa 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,8 +1,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, BooleanColumn from dcim.models import ConsolePort, Interface, PowerPort +from netbox.tables import BaseTable, columns from .cables import * from .devices import * from .devicetypes import * @@ -36,7 +36,7 @@ class ConsoleConnectionTable(BaseTable): linkify=True, verbose_name='Console Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -67,7 +67,7 @@ class PowerConnectionTable(BaseTable): linkify=True, verbose_name='Power Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -101,7 +101,7 @@ class InterfaceConnectionTable(BaseTable): linkify=True, verbose_name='Interface B' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index bea2c0adf..addb67c33 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,8 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT __all__ = ( @@ -16,7 +16,7 @@ __all__ = ( # class CableTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() termination_a_parent = tables.TemplateColumn( template_code=CABLE_TERMINATION_PARENT, accessor=Accessor('termination_a'), @@ -41,14 +41,14 @@ class CableTable(BaseTable): linkify=True, verbose_name='Termination B' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() - length = TemplateColumn( + length = columns.TemplateColumn( template_code=CABLE_LENGTH, order_by='_abs_length' ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:cable_list' ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7b00a16e9..7dee2bcbe 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -5,11 +5,8 @@ from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, - MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, -) from .template_code import * __all__ = ( @@ -75,23 +72,23 @@ def get_interface_state_attribute(record): # class DeviceRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'role_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'role_id': 'pk'}, verbose_name='VMs' ) - color = ColorColumn() - vm_role = BooleanColumn() - tags = TagColumn( + color = columns.ColorColumn() + vm_role = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:devicerole_list' ) @@ -109,21 +106,21 @@ class DeviceRoleTable(BaseTable): # class PlatformTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'platform_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'platform_id': 'pk'}, verbose_name='VMs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:platform_list' ) @@ -143,12 +140,12 @@ class PlatformTable(BaseTable): # class DeviceTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -159,7 +156,7 @@ class DeviceTable(BaseTable): rack = tables.Column( linkify=True ) - device_role = ColoredLabelColumn( + device_role = columns.ColoredLabelColumn( verbose_name='Role' ) manufacturer = tables.Column( @@ -195,8 +192,8 @@ class DeviceTable(BaseTable): vc_priority = tables.Column( verbose_name='VC Priority' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:device_list' ) @@ -218,7 +215,7 @@ class DeviceImportTable(BaseTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -244,7 +241,7 @@ class DeviceImportTable(BaseTable): # class DeviceComponentTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() device = tables.Column( linkify=True ) @@ -274,22 +271,22 @@ class CableTerminationTable(BaseTable): cable = tables.Column( linkify=True ) - cable_color = ColorColumn( + cable_color = columns.ColorColumn( accessor='cable.color', orderable=False, verbose_name='Cable Color' ) - link_peer = TemplateColumn( + link_peer = columns.TemplateColumn( accessor='_link_peer', template_code=LINKTERMINATION, orderable=False, verbose_name='Link Peer' ) - mark_connected = BooleanColumn() + mark_connected = columns.BooleanColumn() class PathEndpointTable(CableTerminationTable): - connection = TemplateColumn( + connection = columns.TemplateColumn( accessor='_path.last_node', template_code=LINKTERMINATION, verbose_name='Connection', @@ -304,7 +301,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleport_list' ) @@ -323,7 +320,7 @@ class DeviceConsolePortTable(ConsolePortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=CONSOLEPORT_BUTTONS ) @@ -346,7 +343,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleserverport_list' ) @@ -366,7 +363,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=CONSOLESERVERPORT_BUTTONS ) @@ -389,7 +386,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerport_list' ) @@ -410,7 +407,7 @@ class DevicePowerPortTable(PowerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=POWERPORT_BUTTONS ) @@ -438,7 +435,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): power_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:poweroutlet_list' ) @@ -458,7 +455,7 @@ class DevicePowerOutletTable(PowerOutletTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=POWEROUTLET_BUTTONS ) @@ -477,7 +474,7 @@ class DevicePowerOutletTable(PowerOutletTable): class BaseInterfaceTable(BaseTable): - enabled = BooleanColumn() + enabled = columns.BooleanColumn() ip_addresses = tables.TemplateColumn( template_code=INTERFACE_IPADDRESSES, orderable=False, @@ -490,7 +487,7 @@ class BaseInterfaceTable(BaseTable): verbose_name='FHRP Groups' ) untagged_vlan = tables.Column(linkify=True) - tagged_vlans = TemplateColumn( + tagged_vlans = columns.TemplateColumn( template_code=INTERFACE_TAGGED_VLANS, orderable=False, verbose_name='Tagged VLANs' @@ -504,11 +501,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'args': [Accessor('device_id')], } ) - mgmt_only = BooleanColumn() + mgmt_only = columns.BooleanColumn() wireless_link = tables.Column( linkify=True ) - wireless_lans = TemplateColumn( + wireless_lans = columns.TemplateColumn( template_code=INTERFACE_WIRELESS_LANS, orderable=False, verbose_name='Wireless LANs' @@ -516,7 +513,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi vrf = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:interface_list' ) @@ -550,7 +547,7 @@ class DeviceInterfaceTable(InterfaceTable): linkify=True, verbose_name='LAG' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=INTERFACE_BUTTONS ) @@ -582,14 +579,14 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): 'args': [Accessor('device_id')], } ) - color = ColorColumn() + color = columns.ColorColumn() rear_port_position = tables.Column( verbose_name='Position' ) rear_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:frontport_list' ) @@ -612,7 +609,7 @@ class DeviceFrontPortTable(FrontPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=FRONTPORT_BUTTONS ) @@ -637,8 +634,8 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): 'args': [Accessor('device_id')], } ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rearport_list' ) @@ -658,7 +655,7 @@ class DeviceRearPortTable(RearPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=REARPORT_BUTTONS ) @@ -690,7 +687,7 @@ class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:devicebay_list' ) @@ -711,7 +708,7 @@ class DeviceDeviceBayTable(DeviceBayTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=DEVICEBAY_BUTTONS ) @@ -734,7 +731,7 @@ class ModuleBayTable(DeviceComponentTable): linkify=True, verbose_name='Installed module' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:modulebay_list' ) @@ -745,7 +742,7 @@ class ModuleBayTable(DeviceComponentTable): class DeviceModuleBayTable(ModuleBayTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=MODULEBAY_BUTTONS ) @@ -773,8 +770,8 @@ class InventoryItemTable(DeviceComponentTable): orderable=False, linkify=True ) - discovered = BooleanColumn() - tags = TagColumn( + discovered = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:inventoryitem_list' ) cable = None # Override DeviceComponentTable @@ -797,7 +794,7 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ActionsColumn() + actions = columns.ActionsColumn() class Meta(BaseTable.Meta): model = InventoryItem @@ -811,17 +808,17 @@ class DeviceInventoryItemTable(InventoryItemTable): class InventoryItemRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - inventoryitem_count = LinkedCountColumn( + inventoryitem_count = columns.LinkedCountColumn( viewname='dcim:inventoryitem_list', url_params={'role_id': 'pk'}, verbose_name='Items' ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:inventoryitemrole_list' ) @@ -838,19 +835,19 @@ class InventoryItemRoleTable(BaseTable): # class VirtualChassisTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) master = tables.Column( linkify=True ) - member_count = LinkedCountColumn( + member_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:virtualchassis_list' ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 93832d706..0b4f04a2a 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -5,9 +5,7 @@ from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, -) +from netbox.tables import BaseTable, columns from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( @@ -31,7 +29,7 @@ __all__ = ( # class ManufacturerTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -45,7 +43,7 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:manufacturer_list' ) @@ -65,21 +63,21 @@ class ManufacturerTable(BaseTable): # class DeviceTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() model = tables.Column( linkify=True, verbose_name='Device Type' ) - is_full_depth = BooleanColumn( + is_full_depth = columns.BooleanColumn( verbose_name='Full Depth' ) - instance_count = LinkedCountColumn( + instance_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'device_type_id': 'pk'}, verbose_name='Instances' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:devicetype_list' ) @@ -99,7 +97,7 @@ class DeviceTypeTable(BaseTable): # class ComponentTemplateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() id = tables.Column( verbose_name='ID' ) @@ -112,7 +110,7 @@ class ComponentTemplateTable(BaseTable): class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -124,7 +122,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -136,7 +134,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -148,7 +146,7 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -160,10 +158,10 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class InterfaceTemplateTable(ComponentTemplateTable): - mgmt_only = BooleanColumn( + mgmt_only = columns.BooleanColumn( verbose_name='Management Only' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -178,8 +176,8 @@ class FrontPortTemplateTable(ComponentTemplateTable): rear_port_position = tables.Column( verbose_name='Position' ) - color = ColorColumn() - actions = ActionsColumn( + color = columns.ColorColumn() + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -191,8 +189,8 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): - color = ColorColumn() - actions = ActionsColumn( + color = columns.ColorColumn() + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) @@ -204,7 +202,7 @@ class RearPortTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) @@ -215,7 +213,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) @@ -226,7 +224,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class InventoryItemTemplateTable(ComponentTemplateTable): - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) role = tables.Column( diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 6d620433a..4a4c9d09a 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Module, ModuleType -from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn +from netbox.tables import BaseTable, columns __all__ = ( 'ModuleTable', @@ -10,18 +10,18 @@ __all__ = ( class ModuleTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() model = tables.Column( linkify=True, verbose_name='Module Type' ) - instance_count = LinkedCountColumn( + instance_count = columns.LinkedCountColumn( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, verbose_name='Instances' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:moduletype_list' ) @@ -36,7 +36,7 @@ class ModuleTypeTable(BaseTable): class ModuleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() device = tables.Column( linkify=True ) @@ -46,8 +46,8 @@ class ModuleTable(BaseTable): module_type = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:module_list' ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index c1ea8a34c..e1c0304a2 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import PowerFeed, PowerPanel -from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn +from netbox.tables import BaseTable, columns from .devices import CableTerminationTable __all__ = ( @@ -15,19 +15,19 @@ __all__ = ( # class PowerPanelTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) site = tables.Column( linkify=True ) - powerfeed_count = LinkedCountColumn( + powerfeed_count = columns.LinkedCountColumn( viewname='dcim:powerfeed_list', url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) @@ -44,7 +44,7 @@ class PowerPanelTable(BaseTable): # We're not using PathEndpointTable for PowerFeed because power connections # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -54,16 +54,16 @@ class PowerFeedTable(CableTerminationTable): rack = tables.Column( linkify=True ) - status = ChoiceFieldColumn() - type = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() + type = columns.ChoiceFieldColumn() max_utilization = tables.TemplateColumn( template_code="{{ value }}%" ) available_power = tables.Column( verbose_name='Available power (VA)' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:powerfeed_list' ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 55c6f9ba8..9e89d7b82 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -2,11 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, UtilizationColumn, -) __all__ = ( 'RackTable', @@ -20,11 +17,11 @@ __all__ = ( # class RackRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rackrole_list' ) @@ -42,7 +39,7 @@ class RackRoleTable(BaseTable): # class RackTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( order_by=('_name',), linkify=True @@ -54,27 +51,27 @@ class RackTable(BaseTable): linkify=True ) tenant = TenantColumn() - status = ChoiceFieldColumn() - role = ColoredLabelColumn() + status = columns.ChoiceFieldColumn() + role = columns.ColoredLabelColumn() u_height = tables.TemplateColumn( template_code="{{ record.u_height }}U", verbose_name='Height' ) - comments = MarkdownColumn() - device_count = LinkedCountColumn( + comments = columns.MarkdownColumn() + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'rack_id': 'pk'}, verbose_name='Devices' ) - get_utilization = UtilizationColumn( + get_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Space' ) - get_power_utilization = UtilizationColumn( + get_power_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Power' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rack_list' ) outer_width = tables.TemplateColumn( @@ -104,7 +101,7 @@ class RackTable(BaseTable): # class RackReservationTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() reservation = tables.Column( accessor='pk', linkify=True @@ -121,7 +118,7 @@ class RackReservationTable(BaseTable): orderable=False, verbose_name='Units' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 32bf000ef..7a4e2f34f 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,10 +1,8 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, -) from .template_code import LOCATION_BUTTONS __all__ = ( @@ -20,16 +18,16 @@ __all__ = ( # class RegionTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'region_id': 'pk'}, verbose_name='Sites' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:region_list' ) @@ -46,16 +44,16 @@ class RegionTable(BaseTable): # class SiteGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'group_id': 'pk'}, verbose_name='Sites' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) @@ -72,26 +70,26 @@ class SiteGroupTable(BaseTable): # class SiteTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() region = tables.Column( linkify=True ) group = tables.Column( linkify=True ) - asn_count = LinkedCountColumn( + asn_count = columns.LinkedCountColumn( accessor=tables.A('asns.count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name='ASNs' ) tenant = TenantColumn() - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:site_list' ) @@ -110,28 +108,28 @@ class SiteTable(BaseTable): # class LocationTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) site = tables.Column( linkify=True ) tenant = TenantColumn() - rack_count = LinkedCountColumn( + rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, verbose_name='Racks' ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'location_id': 'pk'}, verbose_name='Devices' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:location_list' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=LOCATION_BUTTONS ) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b3cff1a26..231a3ef09 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 7d60518b2..b235cd8e2 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,10 +1,7 @@ import django_tables2 as tables from django.conf import settings -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, - MarkdownColumn, ToggleColumn, -) +from netbox.tables import BaseTable, columns from .models import * __all__ = ( @@ -47,12 +44,12 @@ OBJECTCHANGE_REQUEST_ID = """ # class CustomFieldTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_types = ContentTypesColumn() - required = BooleanColumn() + content_types = columns.ContentTypesColumn() + required = columns.BooleanColumn() class Meta(BaseTable.Meta): model = CustomField @@ -68,13 +65,13 @@ class CustomFieldTable(BaseTable): # class CustomLinkTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_type = ContentTypeColumn() - enabled = BooleanColumn() - new_window = BooleanColumn() + content_type = columns.ContentTypeColumn() + enabled = columns.BooleanColumn() + new_window = columns.BooleanColumn() class Meta(BaseTable.Meta): model = CustomLink @@ -90,12 +87,12 @@ class CustomLinkTable(BaseTable): # class ExportTemplateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_type = ContentTypeColumn() - as_attachment = BooleanColumn() + content_type = columns.ContentTypeColumn() + as_attachment = columns.BooleanColumn() class Meta(BaseTable.Meta): model = ExportTemplate @@ -113,22 +110,22 @@ class ExportTemplateTable(BaseTable): # class WebhookTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - content_types = ContentTypesColumn() - enabled = BooleanColumn() - type_create = BooleanColumn( + content_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + type_create = columns.BooleanColumn( verbose_name='Create' ) - type_update = BooleanColumn( + type_update = columns.BooleanColumn( verbose_name='Update' ) - type_delete = BooleanColumn( + type_delete = columns.BooleanColumn( verbose_name='Delete' ) - ssl_validation = BooleanColumn( + ssl_validation = columns.BooleanColumn( verbose_name='SSL Validation' ) @@ -149,11 +146,11 @@ class WebhookTable(BaseTable): # class TagTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - color = ColorColumn() + color = columns.ColorColumn() class Meta(BaseTable.Meta): model = Tag @@ -167,7 +164,7 @@ class TaggedItemTable(BaseTable): linkify=lambda record: record.content_object.get_absolute_url(), accessor='content_object__id' ) - content_type = ContentTypeColumn( + content_type = columns.ContentTypeColumn( verbose_name='Type' ) content_object = tables.Column( @@ -182,11 +179,11 @@ class TaggedItemTable(BaseTable): class ConfigContextTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - is_active = BooleanColumn( + is_active = columns.BooleanColumn( verbose_name='Active' ) @@ -205,8 +202,8 @@ class ObjectChangeTable(BaseTable): linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - action = ChoiceFieldColumn() - changed_object_type = ContentTypeColumn( + action = columns.ChoiceFieldColumn() + changed_object_type = columns.ContentTypeColumn( verbose_name='Type' ) object_repr = tables.TemplateColumn( @@ -217,7 +214,7 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) - actions = ActionsColumn(sequence=()) + actions = columns.ActionsColumn(sequence=()) class Meta(BaseTable.Meta): model = ObjectChange @@ -232,7 +229,7 @@ class ObjectJournalTable(BaseTable): linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - kind = ChoiceFieldColumn() + kind = columns.ChoiceFieldColumn() comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) @@ -243,8 +240,8 @@ class ObjectJournalTable(BaseTable): class JournalEntryTable(ObjectJournalTable): - pk = ToggleColumn() - assigned_object_type = ContentTypeColumn( + pk = columns.ToggleColumn() + assigned_object_type = columns.ContentTypeColumn( verbose_name='Object type' ) assigned_object = tables.Column( @@ -252,7 +249,7 @@ class JournalEntryTable(ObjectJournalTable): orderable=False, verbose_name='Object' ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() class Meta(BaseTable.Meta): model = JournalEntry diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 59f922d82..0c59ae874 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index f9119126c..e200d6cac 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,7 +1,7 @@ import django_tables2 as tables -from utilities.tables import ActionsColumn, BaseTable, MarkdownColumn, TagColumn, ToggleColumn from ipam.models import * +from netbox.tables import BaseTable, columns __all__ = ( 'FHRPGroupTable', @@ -17,11 +17,11 @@ IPADDRESSES = """ class FHRPGroupTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() group_id = tables.Column( linkify=True ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() ip_addresses = tables.TemplateColumn( template_code=IPADDRESSES, orderable=False, @@ -30,7 +30,7 @@ class FHRPGroupTable(BaseTable): interface_count = tables.Column( verbose_name='Interfaces' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:fhrpgroup_list' ) @@ -44,7 +44,7 @@ class FHRPGroupTable(BaseTable): class FHRPGroupAssignmentTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() interface_parent = tables.Column( accessor=tables.A('interface.parent_object'), linkify=True, @@ -58,7 +58,7 @@ class FHRPGroupAssignmentTable(BaseTable): group = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index b2e4ef958..a69118da3 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -3,10 +3,8 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from ipam.models import * +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, UtilizationColumn, -) __all__ = ( 'AggregateTable', @@ -73,19 +71,19 @@ VRF_LINK = """ # class RIRTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - is_private = BooleanColumn( + is_private = columns.BooleanColumn( verbose_name='Private' ) - aggregate_count = LinkedCountColumn( + aggregate_count = columns.LinkedCountColumn( viewname='ipam:aggregate_list', url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:rir_list' ) @@ -103,13 +101,13 @@ class RIRTable(BaseTable): # class ASNTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() asn = tables.Column( accessor=tables.A('asn_asdot'), linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'asn_id': 'pk'}, verbose_name='Sites' @@ -126,7 +124,7 @@ class ASNTable(BaseTable): # class AggregateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() prefix = tables.Column( linkify=True, verbose_name='Aggregate' @@ -139,11 +137,11 @@ class AggregateTable(BaseTable): child_count = tables.Column( verbose_name='Prefixes' ) - utilization = UtilizationColumn( + utilization = columns.UtilizationColumn( accessor='get_utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:aggregate_list' ) @@ -161,21 +159,21 @@ class AggregateTable(BaseTable): # class RoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) - prefix_count = LinkedCountColumn( + prefix_count = columns.LinkedCountColumn( viewname='ipam:prefix_list', url_params={'role_id': 'pk'}, verbose_name='Prefixes' ) - vlan_count = LinkedCountColumn( + vlan_count = columns.LinkedCountColumn( viewname='ipam:vlan_list', url_params={'role_id': 'pk'}, verbose_name='VLANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:role_list' ) @@ -192,7 +190,7 @@ class RoleTable(BaseTable): # Prefixes # -class PrefixUtilizationColumn(UtilizationColumn): +class PrefixUtilizationColumn(columns.UtilizationColumn): """ Extend UtilizationColumn to allow disabling the warning & danger thresholds for prefixes marked as fully utilized. @@ -208,7 +206,7 @@ class PrefixUtilizationColumn(UtilizationColumn): class PrefixTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() prefix = tables.TemplateColumn( template_code=PREFIX_LINK, attrs={'td': {'class': 'text-nowrap'}} @@ -222,7 +220,7 @@ class PrefixTable(BaseTable): accessor=Accessor('_depth'), verbose_name='Depth' ) - children = LinkedCountColumn( + children = columns.LinkedCountColumn( accessor=Accessor('_children'), viewname='ipam:prefix_list', url_params={ @@ -231,7 +229,7 @@ class PrefixTable(BaseTable): }, verbose_name='Children' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) vrf = tables.TemplateColumn( @@ -254,17 +252,17 @@ class PrefixTable(BaseTable): role = tables.Column( linkify=True ) - is_pool = BooleanColumn( + is_pool = columns.BooleanColumn( verbose_name='Pool' ) - mark_utilized = BooleanColumn( + mark_utilized = columns.BooleanColumn( verbose_name='Marked Utilized' ) utilization = PrefixUtilizationColumn( accessor='get_utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:prefix_list' ) @@ -286,7 +284,7 @@ class PrefixTable(BaseTable): # IP ranges # class IPRangeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() start_address = tables.Column( linkify=True ) @@ -294,18 +292,18 @@ class IPRangeTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) role = tables.Column( linkify=True ) tenant = TenantColumn() - utilization = UtilizationColumn( + utilization = columns.UtilizationColumn( accessor='utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:iprange_list' ) @@ -328,7 +326,7 @@ class IPRangeTable(BaseTable): # class IPAddressTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() address = tables.TemplateColumn( template_code=IPADDRESS_LINK, verbose_name='IP Address' @@ -337,10 +335,10 @@ class IPAddressTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) - role = ChoiceFieldColumn() + role = columns.ChoiceFieldColumn() tenant = TenantColumn() assigned_object = tables.Column( linkify=True, @@ -358,12 +356,12 @@ class IPAddressTable(BaseTable): orderable=False, verbose_name='NAT (Inside)' ) - assigned = BooleanColumn( + assigned = columns.BooleanColumn( accessor='assigned_object_id', linkify=True, verbose_name='Assigned' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) @@ -386,7 +384,7 @@ class IPAddressAssignTable(BaseTable): template_code=IPADDRESS_ASSIGN_LINK, verbose_name='IP Address' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() assigned_object = tables.Column( orderable=False ) @@ -410,7 +408,7 @@ class AssignedIPAddressesTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 5c3e14b2c..8b4f389e6 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -1,7 +1,7 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn from ipam.models import * +from netbox.tables import BaseTable, columns __all__ = ( 'ServiceTable', @@ -10,14 +10,14 @@ __all__ = ( class ServiceTemplateTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) ports = tables.Column( accessor=tables.A('port_list') ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:servicetemplate_list' ) @@ -28,7 +28,7 @@ class ServiceTemplateTable(BaseTable): class ServiceTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -39,7 +39,7 @@ class ServiceTable(BaseTable): ports = tables.Column( accessor=tables.A('port_list') ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:service_list' ) diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index d387e24dd..faace1257 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -3,13 +3,10 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from dcim.models import Interface -from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, - TemplateColumn, ToggleColumn, -) -from virtualization.models import VMInterface from ipam.models import * +from netbox.tables import BaseTable, columns +from tenancy.tables import TenantColumn +from virtualization.models import VMInterface __all__ = ( 'InterfaceVLANTable', @@ -62,22 +59,22 @@ VLAN_MEMBER_TAGGED = """ # class VLANGroupTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column(linkify=True) - scope_type = ContentTypeColumn() + scope_type = columns.ContentTypeColumn() scope = tables.Column( linkify=True, orderable=False ) - vlan_count = LinkedCountColumn( + vlan_count = columns.LinkedCountColumn( viewname='ipam:vlan_list', url_params={'group_id': 'pk'}, verbose_name='VLANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vlangroup_list' ) - actions = ActionsColumn( + actions = columns.ActionsColumn( extra_buttons=VLANGROUP_BUTTONS ) @@ -95,7 +92,7 @@ class VLANGroupTable(BaseTable): # class VLANTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() vid = tables.TemplateColumn( template_code=VLAN_LINK, verbose_name='VID' @@ -110,18 +107,18 @@ class VLANTable(BaseTable): linkify=True ) tenant = TenantColumn() - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) role = tables.Column( linkify=True ) - prefixes = TemplateColumn( + prefixes = columns.TemplateColumn( template_code=VLAN_PREFIXES, orderable=False, verbose_name='Prefixes' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vlan_list' ) @@ -155,7 +152,7 @@ class VLANDevicesTable(VLANMembersTable): device = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit',) ) @@ -169,7 +166,7 @@ class VLANVirtualMachinesTable(VLANMembersTable): virtual_machine = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit',) ) @@ -187,7 +184,7 @@ class InterfaceVLANTable(BaseTable): linkify=True, verbose_name='ID' ) - tagged = BooleanColumn() + tagged = columns.BooleanColumn() site = tables.Column( linkify=True ) @@ -196,7 +193,7 @@ class InterfaceVLANTable(BaseTable): verbose_name='Group' ) tenant = TenantColumn() - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() role = tables.Column( linkify=True ) diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index e71fb1fa4..7dbad6420 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -1,8 +1,8 @@ import django_tables2 as tables -from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn from ipam.models import * +from netbox.tables import BaseTable, columns +from tenancy.tables import TenantColumn __all__ = ( 'RouteTargetTable', @@ -21,7 +21,7 @@ VRF_TARGETS = """ # class VRFTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -29,18 +29,18 @@ class VRFTable(BaseTable): verbose_name='RD' ) tenant = TenantColumn() - enforce_unique = BooleanColumn( + enforce_unique = columns.BooleanColumn( verbose_name='Unique' ) - import_targets = TemplateColumn( + import_targets = columns.TemplateColumn( template_code=VRF_TARGETS, orderable=False ) - export_targets = TemplateColumn( + export_targets = columns.TemplateColumn( template_code=VRF_TARGETS, orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vrf_list' ) @@ -58,12 +58,12 @@ class VRFTable(BaseTable): # class RouteTargetTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) tenant = TenantColumn() - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vrf_list' ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 35d5cf502..85c2482e5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from dcim.tables import SiteTable from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface diff --git a/netbox/utilities/tables/__init__.py b/netbox/netbox/tables/__init__.py similarity index 91% rename from netbox/utilities/tables/__init__.py rename to netbox/netbox/tables/__init__.py index 25fa95296..40ae2f547 100644 --- a/netbox/utilities/tables/__init__.py +++ b/netbox/netbox/tables/__init__.py @@ -27,13 +27,3 @@ def configure_table(table, request): 'per_page': get_paginate_count(request) } RequestConfig(request, paginate).configure(table) - - -# -# Callables -# - -def linkify_phone(value): - if value is None: - return None - return f"tel:{value}" diff --git a/netbox/utilities/tables/columns.py b/netbox/netbox/tables/columns.py similarity index 100% rename from netbox/utilities/tables/columns.py rename to netbox/netbox/tables/columns.py diff --git a/netbox/utilities/tables/tables.py b/netbox/netbox/tables/tables.py similarity index 99% rename from netbox/utilities/tables/tables.py rename to netbox/netbox/tables/tables.py index d1915569e..8b8f6ae4c 100644 --- a/netbox/utilities/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -7,7 +7,7 @@ from django.db.models.fields.related import RelatedField from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink -from . import columns +from netbox.tables import columns __all__ = ( 'BaseTable', diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 82e1dc217..9c834d76f 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -21,7 +21,7 @@ from utilities.forms import ( ) from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.views import GetReturnURLMixin from .base import BaseMultiObjectView diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 09a102442..316c3a1ee 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -18,7 +18,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin from .base import BaseObjectView diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 55a0591b5..bbaa4fdff 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,9 +1,7 @@ import django_tables2 as tables -from utilities.tables import ( - ActionsColumn, BaseTable, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, - TagColumn, ToggleColumn, -) +from netbox.tables import BaseTable, columns +from utilities.tables import linkify_phone from .models import * __all__ = ( @@ -47,16 +45,16 @@ class TenantColumn(tables.TemplateColumn): # class TenantGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - tenant_count = LinkedCountColumn( + tenant_count = columns.LinkedCountColumn( viewname='tenancy:tenant_list', url_params={'group_id': 'pk'}, verbose_name='Tenants' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='tenancy:tenantgroup_list' ) @@ -69,15 +67,15 @@ class TenantGroupTable(BaseTable): class TenantTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) group = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='tenancy:tenant_list' ) @@ -92,16 +90,16 @@ class TenantTable(BaseTable): # class ContactGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - contact_count = LinkedCountColumn( + contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', url_params={'role_id': 'pk'}, verbose_name='Contacts' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='tenancy:contactgroup_list' ) @@ -114,7 +112,7 @@ class ContactGroupTable(BaseTable): class ContactRoleTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -126,7 +124,7 @@ class ContactRoleTable(BaseTable): class ContactTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -136,11 +134,11 @@ class ContactTable(BaseTable): phone = tables.Column( linkify=linkify_phone, ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() assignment_count = tables.Column( verbose_name='Assignments' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='tenancy:tenant_list' ) @@ -154,8 +152,8 @@ class ContactTable(BaseTable): class ContactAssignmentTable(BaseTable): - pk = ToggleColumn() - content_type = ContentTypeColumn( + pk = columns.ToggleColumn() + content_type = columns.ContentTypeColumn( verbose_name='Object Type' ) object = tables.Column( @@ -168,7 +166,7 @@ class ContactAssignmentTable(BaseTable): role = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete') ) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 1d2dac0b5..d4ebcb672 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -6,7 +6,7 @@ from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation, Cable from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index 035ca6840..326aac13a 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -6,7 +6,7 @@ from django.urls import reverse from dcim.models import Site from dcim.tables import SiteTable from users.preferences import UserPreference -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.testing import TestCase diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py new file mode 100644 index 000000000..c8420e084 --- /dev/null +++ b/netbox/utilities/tables.py @@ -0,0 +1,7 @@ +def linkify_phone(value): + """ + Render a telephone number as a hyperlink. + """ + if value is None: + return None + return f"tel:{value}" diff --git a/netbox/utilities/tests/test_tables.py b/netbox/utilities/tests/test_tables.py index 55a5e4cc7..7a9f3bd9c 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/utilities/tests/test_tables.py @@ -2,12 +2,12 @@ from django.template import Context, Template from django.test import TestCase from dcim.models import Site -from utilities.tables import BaseTable, TagColumn +from netbox.tables import BaseTable, columns from utilities.testing import create_tags class TagColumnTable(BaseTable): - tags = TagColumn(url_name='dcim:site_list') + tags = columns.TagColumn(url_name='dcim:site_list') class Meta(BaseTable.Meta): model = Site diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 517f0a4b8..950174029 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,11 +1,8 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable +from netbox.tables import BaseTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - ActionsColumn, BaseTable, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, -) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface __all__ = ( @@ -31,14 +28,14 @@ VMINTERFACE_BUTTONS = """ # class ClusterTypeTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) cluster_count = tables.Column( verbose_name='Clusters' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:clustertype_list' ) @@ -55,14 +52,14 @@ class ClusterTypeTable(BaseTable): # class ClusterGroupTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) cluster_count = tables.Column( verbose_name='Clusters' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:clustergroup_list' ) @@ -79,7 +76,7 @@ class ClusterGroupTable(BaseTable): # class ClusterTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -95,18 +92,18 @@ class ClusterTable(BaseTable): site = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'cluster_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'cluster_id': 'pk'}, verbose_name='VMs' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='virtualization:cluster_list' ) @@ -124,18 +121,18 @@ class ClusterTable(BaseTable): # class VirtualMachineTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() name = tables.Column( order_by=('_name',), linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() cluster = tables.Column( linkify=True ) - role = ColoredLabelColumn() + role = columns.ColoredLabelColumn() tenant = TenantColumn() - comments = MarkdownColumn() + comments = columns.MarkdownColumn() primary_ip4 = tables.Column( linkify=True, verbose_name='IPv4 Address' @@ -149,7 +146,7 @@ class VirtualMachineTable(BaseTable): order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:virtualmachine_list' ) @@ -169,14 +166,14 @@ class VirtualMachineTable(BaseTable): # class VMInterfaceTable(BaseInterfaceTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() virtual_machine = tables.Column( linkify=True ) name = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='virtualization:vminterface_list' ) @@ -196,7 +193,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): bridge = tables.Column( linkify=True ) - actions = ActionsColumn( + actions = columns.ActionsColumn( sequence=('edit', 'delete'), extra_buttons=VMINTERFACE_BUTTONS ) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0fc8c9bf7..0957e28a2 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,7 +11,7 @@ from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 650d91554..d1dba993d 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Interface -from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn +from netbox.tables import BaseTable, columns from .models import * __all__ = ( @@ -12,16 +12,16 @@ __all__ = ( class WirelessLANGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( + pk = columns.ToggleColumn() + name = columns.MPTTColumn( linkify=True ) - wirelesslan_count = LinkedCountColumn( + wirelesslan_count = columns.LinkedCountColumn( viewname='wireless:wirelesslan_list', url_params={'group_id': 'pk'}, verbose_name='Wireless LANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='wireless:wirelesslangroup_list' ) @@ -34,7 +34,7 @@ class WirelessLANGroupTable(BaseTable): class WirelessLANTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() ssid = tables.Column( linkify=True ) @@ -44,7 +44,7 @@ class WirelessLANTable(BaseTable): interface_count = tables.Column( verbose_name='Interfaces' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='wireless:wirelesslan_list' ) @@ -58,7 +58,7 @@ class WirelessLANTable(BaseTable): class WirelessLANInterfacesTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() device = tables.Column( linkify=True ) @@ -73,12 +73,12 @@ class WirelessLANInterfacesTable(BaseTable): class WirelessLinkTable(BaseTable): - pk = ToggleColumn() + pk = columns.ToggleColumn() id = tables.Column( linkify=True, verbose_name='ID' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() device_a = tables.Column( accessor=tables.A('interface_a__device'), linkify=True @@ -93,7 +93,7 @@ class WirelessLinkTable(BaseTable): interface_b = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='wireless:wirelesslink_list' ) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 443cf8eef..5ac90f0be 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,6 +1,6 @@ from dcim.models import Interface from netbox.views import generic -from utilities.tables import configure_table +from netbox.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import * From 59d3f5c4ea6576f60a8beafdec6e3c1d56a47c35 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 15:48:05 -0500 Subject: [PATCH 125/271] Split out NetBoxTable from BaseTable --- netbox/circuits/tables.py | 22 +++--- netbox/dcim/tables/__init__.py | 5 +- netbox/dcim/tables/cables.py | 7 +- netbox/dcim/tables/devices.py | 44 +++++------ netbox/dcim/tables/devicetypes.py | 17 ++-- netbox/dcim/tables/modules.py | 12 ++- netbox/dcim/tables/power.py | 10 +-- netbox/dcim/tables/racks.py | 17 ++-- netbox/dcim/tables/sites.py | 22 +++--- netbox/extras/tables.py | 47 +++++------ netbox/ipam/tables/fhrp.py | 12 ++- netbox/ipam/tables/ip.py | 45 +++++------ netbox/ipam/tables/services.py | 12 ++- netbox/ipam/tables/vlans.py | 22 +++--- netbox/ipam/tables/vrfs.py | 12 ++- netbox/netbox/tables/tables.py | 79 +++++++++++++------ .../tests/test_tables.py | 6 +- netbox/tenancy/tables.py | 32 +++----- netbox/virtualization/tables.py | 27 +++---- netbox/wireless/tables.py | 22 +++--- 20 files changed, 218 insertions(+), 254 deletions(-) rename netbox/{utilities => netbox}/tests/test_tables.py (89%) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 0ffb8f03b..56da24842 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .models import * @@ -47,8 +47,7 @@ class CommitRateColumn(tables.TemplateColumn): # Providers # -class ProviderTable(BaseTable): - pk = columns.ToggleColumn() +class ProviderTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -61,7 +60,7 @@ class ProviderTable(BaseTable): url_name='circuits:provider_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Provider fields = ( 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', @@ -74,8 +73,7 @@ class ProviderTable(BaseTable): # Provider networks # -class ProviderNetworkTable(BaseTable): - pk = columns.ToggleColumn() +class ProviderNetworkTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -87,7 +85,7 @@ class ProviderNetworkTable(BaseTable): url_name='circuits:providernetwork_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ProviderNetwork fields = ( 'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags', @@ -99,8 +97,7 @@ class ProviderNetworkTable(BaseTable): # Circuit types # -class CircuitTypeTable(BaseTable): - pk = columns.ToggleColumn() +class CircuitTypeTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -111,7 +108,7 @@ class CircuitTypeTable(BaseTable): verbose_name='Circuits' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CircuitType fields = ( 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -123,8 +120,7 @@ class CircuitTypeTable(BaseTable): # Circuits # -class CircuitTable(BaseTable): - pk = columns.ToggleColumn() +class CircuitTable(NetBoxTable): cid = tables.Column( linkify=True, verbose_name='Circuit ID' @@ -148,7 +144,7 @@ class CircuitTable(BaseTable): url_name='circuits:circuit_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 7567762fa..e3b2a42ba 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,8 +1,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import ConsolePort, Interface, PowerPort from netbox.tables import BaseTable, columns +from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * @@ -44,7 +44,6 @@ class ConsoleConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = ConsolePort fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') - exclude = ('id', ) class PowerConnectionTable(BaseTable): @@ -75,7 +74,6 @@ class PowerConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPort fields = ('device', 'name', 'pdu', 'outlet', 'reachable') - exclude = ('id', ) class InterfaceConnectionTable(BaseTable): @@ -109,4 +107,3 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') - exclude = ('id', ) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index addb67c33..1774a3e22 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT @@ -15,8 +15,7 @@ __all__ = ( # Cables # -class CableTable(BaseTable): - pk = columns.ToggleColumn() +class CableTable(NetBoxTable): termination_a_parent = tables.TemplateColumn( template_code=CABLE_TERMINATION_PARENT, accessor=Accessor('termination_a'), @@ -52,7 +51,7 @@ class CableTable(BaseTable): url_name='dcim:cable_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7dee2bcbe..37faaae7f 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -5,7 +5,7 @@ from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .template_code import * @@ -71,8 +71,7 @@ def get_interface_state_attribute(record): # Device roles # -class DeviceRoleTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceRoleTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -92,7 +91,7 @@ class DeviceRoleTable(BaseTable): url_name='dcim:devicerole_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', @@ -105,8 +104,7 @@ class DeviceRoleTable(BaseTable): # Platforms # -class PlatformTable(BaseTable): - pk = columns.ToggleColumn() +class PlatformTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -124,7 +122,7 @@ class PlatformTable(BaseTable): url_name='dcim:platform_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', @@ -139,8 +137,7 @@ class PlatformTable(BaseTable): # Devices # -class DeviceTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceTable(NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK @@ -197,7 +194,7 @@ class DeviceTable(BaseTable): url_name='dcim:device_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', @@ -211,7 +208,7 @@ class DeviceTable(BaseTable): ) -class DeviceImportTable(BaseTable): +class DeviceImportTable(NetBoxTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) @@ -230,7 +227,7 @@ class DeviceImportTable(BaseTable): verbose_name='Type' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False @@ -240,8 +237,7 @@ class DeviceImportTable(BaseTable): # Device components # -class DeviceComponentTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceComponentTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -250,7 +246,7 @@ class DeviceComponentTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): order_by = ('device', 'name') @@ -267,7 +263,7 @@ class ModularDeviceComponentTable(DeviceComponentTable): ) -class CableTerminationTable(BaseTable): +class CableTerminationTable(NetBoxTable): cable = tables.Column( linkify=True ) @@ -473,7 +469,7 @@ class DevicePowerOutletTable(PowerOutletTable): } -class BaseInterfaceTable(BaseTable): +class BaseInterfaceTable(NetBoxTable): enabled = columns.BooleanColumn() ip_addresses = tables.TemplateColumn( template_code=INTERFACE_IPADDRESSES, @@ -776,7 +772,7 @@ class InventoryItemTable(DeviceComponentTable): ) cable = None # Override DeviceComponentTable - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', @@ -796,7 +792,7 @@ class DeviceInventoryItemTable(InventoryItemTable): ) actions = columns.ActionsColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', @@ -807,8 +803,7 @@ class DeviceInventoryItemTable(InventoryItemTable): ) -class InventoryItemRoleTable(BaseTable): - pk = columns.ToggleColumn() +class InventoryItemRoleTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -822,7 +817,7 @@ class InventoryItemRoleTable(BaseTable): url_name='dcim:inventoryitemrole_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItemRole fields = ( 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', @@ -834,8 +829,7 @@ class InventoryItemRoleTable(BaseTable): # Virtual chassis # -class VirtualChassisTable(BaseTable): - pk = columns.ToggleColumn() +class VirtualChassisTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -851,7 +845,7 @@ class VirtualChassisTable(BaseTable): url_name='dcim:virtualchassis_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VirtualChassis fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 0b4f04a2a..44848f6ba 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -5,7 +5,7 @@ from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( @@ -28,8 +28,7 @@ __all__ = ( # Manufacturers # -class ManufacturerTable(BaseTable): - pk = columns.ToggleColumn() +class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -47,7 +46,7 @@ class ManufacturerTable(BaseTable): url_name='dcim:manufacturer_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', @@ -62,8 +61,7 @@ class ManufacturerTable(BaseTable): # Device types # -class DeviceTypeTable(BaseTable): - pk = columns.ToggleColumn() +class DeviceTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Device Type' @@ -81,7 +79,7 @@ class DeviceTypeTable(BaseTable): url_name='dcim:devicetype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', @@ -96,8 +94,7 @@ class DeviceTypeTable(BaseTable): # Device type components # -class ComponentTemplateTable(BaseTable): - pk = columns.ToggleColumn() +class ComponentTemplateTable(NetBoxTable): id = tables.Column( verbose_name='ID' ) @@ -105,7 +102,7 @@ class ComponentTemplateTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): exclude = ('id', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 4a4c9d09a..5b009e42e 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Module, ModuleType -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns __all__ = ( 'ModuleTable', @@ -9,8 +9,7 @@ __all__ = ( ) -class ModuleTypeTable(BaseTable): - pk = columns.ToggleColumn() +class ModuleTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Module Type' @@ -25,7 +24,7 @@ class ModuleTypeTable(BaseTable): url_name='dcim:moduletype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ModuleType fields = ( 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', @@ -35,8 +34,7 @@ class ModuleTypeTable(BaseTable): ) -class ModuleTable(BaseTable): - pk = columns.ToggleColumn() +class ModuleTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -51,7 +49,7 @@ class ModuleTable(BaseTable): url_name='dcim:module_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Module fields = ( 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index e1c0304a2..99bc963f9 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import PowerFeed, PowerPanel -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .devices import CableTerminationTable __all__ = ( @@ -14,8 +14,7 @@ __all__ = ( # Power panels # -class PowerPanelTable(BaseTable): - pk = columns.ToggleColumn() +class PowerPanelTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -31,7 +30,7 @@ class PowerPanelTable(BaseTable): url_name='dcim:powerpanel_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerPanel fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -44,7 +43,6 @@ class PowerPanelTable(BaseTable): # We're not using PathEndpointTable for PowerFeed because power connections # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): - pk = columns.ToggleColumn() name = tables.Column( linkify=True ) @@ -67,7 +65,7 @@ class PowerFeedTable(CableTerminationTable): url_name='dcim:powerfeed_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerFeed fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 9e89d7b82..416e9e8ff 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn __all__ = ( @@ -16,8 +16,7 @@ __all__ = ( # Rack roles # -class RackRoleTable(BaseTable): - pk = columns.ToggleColumn() +class RackRoleTable(NetBoxTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') color = columns.ColorColumn() @@ -25,7 +24,7 @@ class RackRoleTable(BaseTable): url_name='dcim:rackrole_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackRole fields = ( 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created', @@ -38,8 +37,7 @@ class RackRoleTable(BaseTable): # Racks # -class RackTable(BaseTable): - pk = columns.ToggleColumn() +class RackTable(NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -83,7 +81,7 @@ class RackTable(BaseTable): verbose_name='Outer Depth' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', @@ -100,8 +98,7 @@ class RackTable(BaseTable): # Rack reservations # -class RackReservationTable(BaseTable): - pk = columns.ToggleColumn() +class RackReservationTable(NetBoxTable): reservation = tables.Column( accessor='pk', linkify=True @@ -122,7 +119,7 @@ class RackReservationTable(BaseTable): url_name='dcim:rackreservation_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackReservation fields = ( 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 7a4e2f34f..1be1f74d0 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .template_code import LOCATION_BUTTONS @@ -17,8 +17,7 @@ __all__ = ( # Regions # -class RegionTable(BaseTable): - pk = columns.ToggleColumn() +class RegionTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -31,7 +30,7 @@ class RegionTable(BaseTable): url_name='dcim:region_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Region fields = ( 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', @@ -43,8 +42,7 @@ class RegionTable(BaseTable): # Site groups # -class SiteGroupTable(BaseTable): - pk = columns.ToggleColumn() +class SiteGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -57,7 +55,7 @@ class SiteGroupTable(BaseTable): url_name='dcim:sitegroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = SiteGroup fields = ( 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions', @@ -69,8 +67,7 @@ class SiteGroupTable(BaseTable): # Sites # -class SiteTable(BaseTable): - pk = columns.ToggleColumn() +class SiteTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -93,7 +90,7 @@ class SiteTable(BaseTable): url_name='dcim:site_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Site fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', @@ -107,8 +104,7 @@ class SiteTable(BaseTable): # Locations # -class LocationTable(BaseTable): - pk = columns.ToggleColumn() +class LocationTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -133,7 +129,7 @@ class LocationTable(BaseTable): extra_buttons=LOCATION_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Location fields = ( 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index b235cd8e2..52aeb9708 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django.conf import settings -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .models import * __all__ = ( @@ -43,15 +43,14 @@ OBJECTCHANGE_REQUEST_ID = """ # Custom fields # -class CustomFieldTable(BaseTable): - pk = columns.ToggleColumn() +class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True ) content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', @@ -64,8 +63,7 @@ class CustomFieldTable(BaseTable): # Custom links # -class CustomLinkTable(BaseTable): - pk = columns.ToggleColumn() +class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -73,7 +71,7 @@ class CustomLinkTable(BaseTable): enabled = columns.BooleanColumn() new_window = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CustomLink fields = ( 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', @@ -86,15 +84,14 @@ class CustomLinkTable(BaseTable): # Export templates # -class ExportTemplateTable(BaseTable): - pk = columns.ToggleColumn() +class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True ) content_type = columns.ContentTypeColumn() as_attachment = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', @@ -109,8 +106,7 @@ class ExportTemplateTable(BaseTable): # Webhooks # -class WebhookTable(BaseTable): - pk = columns.ToggleColumn() +class WebhookTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -129,7 +125,7 @@ class WebhookTable(BaseTable): verbose_name='SSL Validation' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Webhook fields = ( 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', @@ -145,20 +141,19 @@ class WebhookTable(BaseTable): # Tags # -class TagTable(BaseTable): - pk = columns.ToggleColumn() +class TagTable(NetBoxTable): name = tables.Column( linkify=True ) color = columns.ColorColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Tag fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') -class TaggedItemTable(BaseTable): +class TaggedItemTable(NetBoxTable): id = tables.Column( verbose_name='ID', linkify=lambda record: record.content_object.get_absolute_url(), @@ -173,13 +168,12 @@ class TaggedItemTable(BaseTable): verbose_name='Object' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = TaggedItem fields = ('id', 'content_type', 'content_object') -class ConfigContextTable(BaseTable): - pk = columns.ToggleColumn() +class ConfigContextTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -187,7 +181,7 @@ class ConfigContextTable(BaseTable): verbose_name='Active' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', @@ -197,7 +191,7 @@ class ConfigContextTable(BaseTable): default_columns = ('pk', 'name', 'weight', 'is_active', 'description') -class ObjectChangeTable(BaseTable): +class ObjectChangeTable(NetBoxTable): time = tables.DateTimeColumn( linkify=True, format=settings.SHORT_DATETIME_FORMAT @@ -216,12 +210,12 @@ class ObjectChangeTable(BaseTable): ) actions = columns.ActionsColumn(sequence=()) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ObjectChange fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') -class ObjectJournalTable(BaseTable): +class ObjectJournalTable(NetBoxTable): """ Used for displaying a set of JournalEntries within the context of a single object. """ @@ -234,13 +228,12 @@ class ObjectJournalTable(BaseTable): template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = JournalEntry fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions') class JournalEntryTable(ObjectJournalTable): - pk = columns.ToggleColumn() assigned_object_type = columns.ContentTypeColumn( verbose_name='Object type' ) @@ -251,7 +244,7 @@ class JournalEntryTable(ObjectJournalTable): ) comments = columns.MarkdownColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = JournalEntry fields = ( 'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index e200d6cac..8848cb079 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,7 +1,7 @@ import django_tables2 as tables from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns __all__ = ( 'FHRPGroupTable', @@ -16,8 +16,7 @@ IPADDRESSES = """ """ -class FHRPGroupTable(BaseTable): - pk = columns.ToggleColumn() +class FHRPGroupTable(NetBoxTable): group_id = tables.Column( linkify=True ) @@ -34,7 +33,7 @@ class FHRPGroupTable(BaseTable): url_name='ipam:fhrpgroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', @@ -43,8 +42,7 @@ class FHRPGroupTable(BaseTable): default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') -class FHRPGroupAssignmentTable(BaseTable): - pk = columns.ToggleColumn() +class FHRPGroupAssignmentTable(NetBoxTable): interface_parent = tables.Column( accessor=tables.A('interface.parent_object'), linkify=True, @@ -62,7 +60,7 @@ class FHRPGroupAssignmentTable(BaseTable): sequence=('edit', 'delete') ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = FHRPGroupAssignment fields = ('pk', 'group', 'interface_parent', 'interface', 'priority') exclude = ('id',) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index a69118da3..762857136 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -3,7 +3,7 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn __all__ = ( @@ -70,8 +70,7 @@ VRF_LINK = """ # RIRs # -class RIRTable(BaseTable): - pk = columns.ToggleColumn() +class RIRTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -87,7 +86,7 @@ class RIRTable(BaseTable): url_name='ipam:rir_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RIR fields = ( 'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'created', @@ -100,8 +99,7 @@ class RIRTable(BaseTable): # ASNs # -class ASNTable(BaseTable): - pk = columns.ToggleColumn() +class ASNTable(NetBoxTable): asn = tables.Column( accessor=tables.A('asn_asdot'), linkify=True @@ -113,7 +111,7 @@ class ASNTable(BaseTable): verbose_name='Sites' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ASN fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated', 'actions') default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant') @@ -123,8 +121,7 @@ class ASNTable(BaseTable): # Aggregates # -class AggregateTable(BaseTable): - pk = columns.ToggleColumn() +class AggregateTable(NetBoxTable): prefix = tables.Column( linkify=True, verbose_name='Aggregate' @@ -145,7 +142,7 @@ class AggregateTable(BaseTable): url_name='ipam:aggregate_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Aggregate fields = ( 'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags', @@ -158,8 +155,7 @@ class AggregateTable(BaseTable): # Roles # -class RoleTable(BaseTable): - pk = columns.ToggleColumn() +class RoleTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -177,7 +173,7 @@ class RoleTable(BaseTable): url_name='ipam:role_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Role fields = ( 'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'created', @@ -205,8 +201,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): """ -class PrefixTable(BaseTable): - pk = columns.ToggleColumn() +class PrefixTable(NetBoxTable): prefix = tables.TemplateColumn( template_code=PREFIX_LINK, attrs={'td': {'class': 'text-nowrap'}} @@ -266,7 +261,7 @@ class PrefixTable(BaseTable): url_name='ipam:prefix_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Prefix fields = ( 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', @@ -283,8 +278,7 @@ class PrefixTable(BaseTable): # # IP ranges # -class IPRangeTable(BaseTable): - pk = columns.ToggleColumn() +class IPRangeTable(NetBoxTable): start_address = tables.Column( linkify=True ) @@ -307,7 +301,7 @@ class IPRangeTable(BaseTable): url_name='ipam:iprange_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPRange fields = ( 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -325,8 +319,7 @@ class IPRangeTable(BaseTable): # IPAddresses # -class IPAddressTable(BaseTable): - pk = columns.ToggleColumn() +class IPAddressTable(NetBoxTable): address = tables.TemplateColumn( template_code=IPADDRESS_LINK, verbose_name='IP Address' @@ -365,7 +358,7 @@ class IPAddressTable(BaseTable): url_name='ipam:ipaddress_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ( 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', @@ -379,7 +372,7 @@ class IPAddressTable(BaseTable): } -class IPAddressAssignTable(BaseTable): +class IPAddressAssignTable(NetBoxTable): address = tables.TemplateColumn( template_code=IPADDRESS_ASSIGN_LINK, verbose_name='IP Address' @@ -389,14 +382,14 @@ class IPAddressAssignTable(BaseTable): orderable=False ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description') exclude = ('id', ) orderable = False -class AssignedIPAddressesTable(BaseTable): +class AssignedIPAddressesTable(NetBoxTable): """ List IP addresses assigned to an object. """ @@ -411,7 +404,7 @@ class AssignedIPAddressesTable(BaseTable): status = columns.ChoiceFieldColumn() tenant = TenantColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description') exclude = ('id', ) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8b4f389e6..8c81a28c2 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -1,7 +1,7 @@ import django_tables2 as tables from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns __all__ = ( 'ServiceTable', @@ -9,8 +9,7 @@ __all__ = ( ) -class ServiceTemplateTable(BaseTable): - pk = columns.ToggleColumn() +class ServiceTemplateTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -21,14 +20,13 @@ class ServiceTemplateTable(BaseTable): url_name='ipam:servicetemplate_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ServiceTemplate fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags') default_columns = ('pk', 'name', 'protocol', 'ports', 'description') -class ServiceTable(BaseTable): - pk = columns.ToggleColumn() +class ServiceTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -43,7 +41,7 @@ class ServiceTable(BaseTable): url_name='ipam:service_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Service fields = ( 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index faace1257..192da0813 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -4,7 +4,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from virtualization.models import VMInterface @@ -58,8 +58,7 @@ VLAN_MEMBER_TAGGED = """ # VLAN groups # -class VLANGroupTable(BaseTable): - pk = columns.ToggleColumn() +class VLANGroupTable(NetBoxTable): name = tables.Column(linkify=True) scope_type = columns.ContentTypeColumn() scope = tables.Column( @@ -78,7 +77,7 @@ class VLANGroupTable(BaseTable): extra_buttons=VLANGROUP_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', @@ -91,8 +90,7 @@ class VLANGroupTable(BaseTable): # VLANs # -class VLANTable(BaseTable): - pk = columns.ToggleColumn() +class VLANTable(NetBoxTable): vid = tables.TemplateColumn( template_code=VLAN_LINK, verbose_name='VID' @@ -122,7 +120,7 @@ class VLANTable(BaseTable): url_name='ipam:vlan_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLAN fields = ( 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags', @@ -134,7 +132,7 @@ class VLANTable(BaseTable): } -class VLANMembersTable(BaseTable): +class VLANMembersTable(NetBoxTable): """ Base table for Interface and VMInterface assignments """ @@ -156,7 +154,7 @@ class VLANDevicesTable(VLANMembersTable): sequence=('edit',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Interface fields = ('device', 'name', 'tagged', 'actions') exclude = ('id', ) @@ -170,13 +168,13 @@ class VLANVirtualMachinesTable(VLANMembersTable): sequence=('edit',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VMInterface fields = ('virtual_machine', 'name', 'tagged', 'actions') exclude = ('id', ) -class InterfaceVLANTable(BaseTable): +class InterfaceVLANTable(NetBoxTable): """ List VLANs assigned to a specific Interface. """ @@ -198,7 +196,7 @@ class InterfaceVLANTable(BaseTable): linkify=True ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLAN fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') exclude = ('id', ) diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 7dbad6420..727f402ff 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -1,7 +1,7 @@ import django_tables2 as tables from ipam.models import * -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn __all__ = ( @@ -20,8 +20,7 @@ VRF_TARGETS = """ # VRFs # -class VRFTable(BaseTable): - pk = columns.ToggleColumn() +class VRFTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -44,7 +43,7 @@ class VRFTable(BaseTable): url_name='ipam:vrf_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VRF fields = ( 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', @@ -57,8 +56,7 @@ class VRFTable(BaseTable): # Route targets # -class RouteTargetTable(BaseTable): - pk = columns.ToggleColumn() +class RouteTargetTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -67,7 +65,7 @@ class RouteTargetTable(BaseTable): url_name='ipam:vrf_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RouteTarget fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8b8f6ae4c..fe422118c 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -11,49 +11,37 @@ from netbox.tables import columns __all__ = ( 'BaseTable', + 'NetBoxTable', ) class BaseTable(tables.Table): """ - Default table for object lists + Base table class for NetBox objects. Adds support for: + + * User configuration (column preferences) + * Automatic prefetching of related objects + * BS5 styling :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. """ - id = tables.Column( - linkify=True, - verbose_name='ID' - ) - actions = columns.ActionsColumn() + exempt_columns = () class Meta: attrs = { 'class': 'table table-hover object-list', } - def __init__(self, *args, user=None, extra_columns=None, **kwargs): - if extra_columns is None: - extra_columns = [] + def __init__(self, *args, user=None, **kwargs): - # Add custom field & custom link columns - content_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(content_types=content_type) - extra_columns.extend([ - (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields - ]) - custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) - extra_columns.extend([ - (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links - ]) - - super().__init__(*args, extra_columns=extra_columns, **kwargs) + super().__init__(*args, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" - # Hide non-default columns (except for actions) - default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), 'actions'] + # Hide non-default columns + default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), *self.exempt_columns] for column in self.columns: if column.name not in default_columns: self.columns.hide(column.name) @@ -65,7 +53,7 @@ class BaseTable(tables.Table): # Show only persistent or selected columns for name, column in self.columns.items(): - if name in ['pk', 'actions', *selected_columns]: + if name in [*self.exempt_columns, *selected_columns]: self.columns.show(name) else: self.columns.hide(name) @@ -116,7 +104,7 @@ class BaseTable(tables.Table): def _get_columns(self, visible=True): columns = [] for name, column in self.columns.items(): - if column.visible == visible and name not in ['pk', 'actions']: + if column.visible == visible and name not in self.exempt_columns: columns.append((name, column.verbose_name)) return columns @@ -137,3 +125,44 @@ class BaseTable(tables.Table): if not hasattr(self, '_objects_count'): self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) return self._objects_count + + +class NetBoxTable(BaseTable): + """ + Table class for most NetBox objects. Adds support for custom field & custom link columns. Includes + default columns for: + + * PK (row selection) + * ID + * Actions + """ + pk = columns.ToggleColumn( + visible=False + ) + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + actions = columns.ActionsColumn() + + exempt_columns = ('pk', 'actions') + + class Meta(BaseTable.Meta): + pass + + def __init__(self, *args, extra_columns=None, **kwargs): + if extra_columns is None: + extra_columns = [] + + # Add custom field & custom link columns + content_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = CustomField.objects.filter(content_types=content_type) + extra_columns.extend([ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields + ]) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + extra_columns.extend([ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links + ]) + + super().__init__(*args, extra_columns=extra_columns, **kwargs) diff --git a/netbox/utilities/tests/test_tables.py b/netbox/netbox/tests/test_tables.py similarity index 89% rename from netbox/utilities/tests/test_tables.py rename to netbox/netbox/tests/test_tables.py index 7a9f3bd9c..17b9743cd 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/netbox/tests/test_tables.py @@ -2,14 +2,14 @@ from django.template import Context, Template from django.test import TestCase from dcim.models import Site -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from utilities.testing import create_tags -class TagColumnTable(BaseTable): +class TagColumnTable(NetBoxTable): tags = columns.TagColumn(url_name='dcim:site_list') - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Site fields = ('pk', 'name', 'tags',) default_columns = fields diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index bbaa4fdff..4f90ee01f 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from utilities.tables import linkify_phone from .models import * @@ -44,8 +44,7 @@ class TenantColumn(tables.TemplateColumn): # Tenants # -class TenantGroupTable(BaseTable): - pk = columns.ToggleColumn() +class TenantGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -58,7 +57,7 @@ class TenantGroupTable(BaseTable): url_name='tenancy:tenantgroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = TenantGroup fields = ( 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -66,8 +65,7 @@ class TenantGroupTable(BaseTable): default_columns = ('pk', 'name', 'tenant_count', 'description') -class TenantTable(BaseTable): - pk = columns.ToggleColumn() +class TenantTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -79,7 +77,7 @@ class TenantTable(BaseTable): url_name='tenancy:tenant_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Tenant fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'group', 'description') @@ -89,8 +87,7 @@ class TenantTable(BaseTable): # Contacts # -class ContactGroupTable(BaseTable): - pk = columns.ToggleColumn() +class ContactGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -103,7 +100,7 @@ class ContactGroupTable(BaseTable): url_name='tenancy:contactgroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ContactGroup fields = ( 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -111,20 +108,18 @@ class ContactGroupTable(BaseTable): default_columns = ('pk', 'name', 'contact_count', 'description') -class ContactRoleTable(BaseTable): - pk = columns.ToggleColumn() +class ContactRoleTable(NetBoxTable): name = tables.Column( linkify=True ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ContactRole fields = ('pk', 'name', 'description', 'slug', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'description') -class ContactTable(BaseTable): - pk = columns.ToggleColumn() +class ContactTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -142,7 +137,7 @@ class ContactTable(BaseTable): url_name='tenancy:tenant_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Contact fields = ( 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags', @@ -151,8 +146,7 @@ class ContactTable(BaseTable): default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') -class ContactAssignmentTable(BaseTable): - pk = columns.ToggleColumn() +class ContactAssignmentTable(NetBoxTable): content_type = columns.ContentTypeColumn( verbose_name='Object Type' ) @@ -170,7 +164,7 @@ class ContactAssignmentTable(BaseTable): sequence=('edit', 'delete') ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ContactAssignment fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 950174029..e1156627a 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.tables.devices import BaseInterfaceTable -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -27,8 +27,7 @@ VMINTERFACE_BUTTONS = """ # Cluster types # -class ClusterTypeTable(BaseTable): - pk = columns.ToggleColumn() +class ClusterTypeTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -39,7 +38,7 @@ class ClusterTypeTable(BaseTable): url_name='virtualization:clustertype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ClusterType fields = ( 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'created', 'last_updated', 'tags', 'actions', @@ -51,8 +50,7 @@ class ClusterTypeTable(BaseTable): # Cluster groups # -class ClusterGroupTable(BaseTable): - pk = columns.ToggleColumn() +class ClusterGroupTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -63,7 +61,7 @@ class ClusterGroupTable(BaseTable): url_name='virtualization:clustergroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ClusterGroup fields = ( 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'created', 'last_updated', 'actions', @@ -75,8 +73,7 @@ class ClusterGroupTable(BaseTable): # Clusters # -class ClusterTable(BaseTable): - pk = columns.ToggleColumn() +class ClusterTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -107,7 +104,7 @@ class ClusterTable(BaseTable): url_name='virtualization:cluster_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Cluster fields = ( 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags', @@ -120,8 +117,7 @@ class ClusterTable(BaseTable): # Virtual machines # -class VirtualMachineTable(BaseTable): - pk = columns.ToggleColumn() +class VirtualMachineTable(NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -150,7 +146,7 @@ class VirtualMachineTable(BaseTable): url_name='virtualization:virtualmachine_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', @@ -166,7 +162,6 @@ class VirtualMachineTable(BaseTable): # class VMInterfaceTable(BaseInterfaceTable): - pk = columns.ToggleColumn() virtual_machine = tables.Column( linkify=True ) @@ -177,7 +172,7 @@ class VMInterfaceTable(BaseInterfaceTable): url_name='virtualization:vminterface_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', @@ -198,7 +193,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): extra_buttons=VMINTERFACE_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VMInterface fields = ( 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index d1dba993d..8dd81dffd 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Interface -from netbox.tables import BaseTable, columns +from netbox.tables import NetBoxTable, columns from .models import * __all__ = ( @@ -11,8 +11,7 @@ __all__ = ( ) -class WirelessLANGroupTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLANGroupTable(NetBoxTable): name = columns.MPTTColumn( linkify=True ) @@ -25,7 +24,7 @@ class WirelessLANGroupTable(BaseTable): url_name='wireless:wirelesslangroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = WirelessLANGroup fields = ( 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', @@ -33,8 +32,7 @@ class WirelessLANGroupTable(BaseTable): default_columns = ('pk', 'name', 'wirelesslan_count', 'description') -class WirelessLANTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLANTable(NetBoxTable): ssid = tables.Column( linkify=True ) @@ -48,7 +46,7 @@ class WirelessLANTable(BaseTable): url_name='wireless:wirelesslan_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', @@ -57,8 +55,7 @@ class WirelessLANTable(BaseTable): default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') -class WirelessLANInterfacesTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLANInterfacesTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -66,14 +63,13 @@ class WirelessLANInterfacesTable(BaseTable): linkify=True ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Interface fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel') default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel') -class WirelessLinkTable(BaseTable): - pk = columns.ToggleColumn() +class WirelessLinkTable(NetBoxTable): id = tables.Column( linkify=True, verbose_name='ID' @@ -97,7 +93,7 @@ class WirelessLinkTable(BaseTable): url_name='wireless:wirelesslink_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = WirelessLink fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', From 3c1ea5d0fb2fabeda539e1cdc23e62bcd15b7b8d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 16:14:02 -0500 Subject: [PATCH 126/271] Closes #8470: Expose NetBoxTable in the plugins framework --- docs/plugins/development/tables.md | 37 ++++++++++++++++++++++++++++++ mkdocs.yml | 5 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 docs/plugins/development/tables.md diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md new file mode 100644 index 000000000..16f9f6c17 --- /dev/null +++ b/docs/plugins/development/tables.md @@ -0,0 +1,37 @@ +# Tables + +NetBox employs the [`django-tables2`](https://django-tables2.readthedocs.io/) library for rendering dynamic object tables. These tables display lists of objects, and can be sorted and filtered by various parameters. + +## NetBoxTable + +To provide additional functionality beyond what is supported by the stock `Table` class in `django-tables2`, NetBox provides the `NetBoxTable` class. This custom table class includes support for: + +* User-configurable column display and ordering +* Custom field & custom link columns +* Automatic prefetching of related objects + +It also includes several default columns: + +* `pk` - A checkbox for selecting the object associated with each table row +* `id` - The object's numeric database ID, as a hyperlink to the object's view +* `actions` - A dropdown menu presenting object-specific actions available to the user. + +### Example + +```python +# tables.py +import django_tables2 as tables +from netbox.tables import NetBoxTable +from .models import MyModel + +class MyModelTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + ... + + class Meta(NetBoxTable.Meta): + model = MyModel + fields = ('pk', 'id', 'name', ...) + default_columns = ('pk', 'name', ...) +``` diff --git a/mkdocs.yml b/mkdocs.yml index 1a77cb195..3b1e52f50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -102,9 +102,10 @@ nav: - Using Plugins: 'plugins/index.md' - Developing Plugins: - Getting Started: 'plugins/development/index.md' - - Database Models: 'plugins/development/models.md' + - Models: 'plugins/development/models.md' - Views: 'plugins/development/views.md' - - Filtersets: 'plugins/development/filtersets.md' + - Tables: 'plugins/development/tables.md' + - Filter Sets: 'plugins/development/filtersets.md' - REST API: 'plugins/development/rest-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Administration: From f1697c68566e5573ebb39848b1a2dd5bbadc9636 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 27 Jan 2022 16:21:19 -0500 Subject: [PATCH 127/271] Add change log for plugins framework additions --- docs/release-notes/version-3.2.md | 38 +++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 789003cca..1b4a7ef87 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,23 +14,15 @@ ### New Features -#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) +#### Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) -A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. +NetBox's plugins framework has been extended considerably in this release. Changes include: -#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) +* Seven generic view classes are now officially supported for use by plugins. +* `NetBoxModel` is available for subclassing to enable various NetBox features, such as custom fields and change logging. +* `NetBoxModelFilterSet` is available to extend NetBox's dynamic filtering ability to plugin models. -A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. - -#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) - -A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. - -#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) - -Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. - -Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object. +No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized. #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) @@ -38,6 +30,12 @@ Several new models have been added to support field-replaceable device modules, Automatic renaming of module components is also supported. When a new module is created, any occurrence of the string `{module}` in a component name will be replaced with the position of the module bay into which the module is being installed. +#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) + +Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. + +Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object. + #### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) Custom choices can be now added to most status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field: @@ -52,12 +50,24 @@ FIELD_CHOICES = { } ``` +#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) + +A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. + #### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) Inventory items can now be templatized on a device type similar to the other component types. This enables users to better pre-model fixed hardware components. Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other components. These relationships will be mirrored when instantiating inventory items on a newly-created device. +#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) + +A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. + +#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) + +A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. + ### Enhancements * [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators From 0fe72376b158ebcfca02f29a92c2c0ce29be91c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 28 Jan 2022 10:00:36 -0500 Subject: [PATCH 128/271] Remove unused form attribute from BulkDeleteView --- netbox/netbox/views/generic/bulk_views.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 9c834d76f..a042a3cff 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -675,14 +675,13 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): """ Delete objects in bulk. - filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being deleted - form: The form class used to delete objects in bulk + Attributes: + filterset: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being deleted """ template_name = 'generic/object_bulk_delete.html' filterset = None table = None - form = None def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') @@ -694,9 +693,6 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): class BulkDeleteForm(ConfirmationForm): pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) - if self.form: - return self.form - return BulkDeleteForm # From f4776731ecc3d815c8002e9bbc50b81299e34403 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 28 Jan 2022 15:48:15 -0500 Subject: [PATCH 129/271] Establish 4 core forms in netbox.forms.base --- docs/plugins/development/forms.md | 10 ++ mkdocs.yml | 1 + netbox/circuits/forms/bulk_edit.py | 10 +- netbox/circuits/forms/bulk_import.py | 10 +- netbox/circuits/forms/filtersets.py | 10 +- netbox/circuits/forms/models.py | 10 +- netbox/dcim/forms/bulk_edit.py | 70 +++++------- netbox/dcim/forms/bulk_import.py | 56 ++++----- netbox/dcim/forms/connections.py | 8 +- netbox/dcim/forms/filtersets.py | 43 +++---- netbox/dcim/forms/models.py | 60 +++++----- netbox/dcim/forms/object_create.py | 4 +- netbox/extras/forms/customfields.py | 81 ------------- netbox/extras/forms/models.py | 17 --- netbox/ipam/forms/bulk_edit.py | 28 ++--- netbox/ipam/forms/bulk_import.py | 30 ++--- netbox/ipam/forms/filtersets.py | 28 ++--- netbox/ipam/forms/models.py | 32 +++--- netbox/netbox/{forms.py => forms/__init__.py} | 1 + netbox/netbox/forms/base.py | 108 ++++++++++++++++++ netbox/tenancy/forms/bulk_edit.py | 12 +- netbox/tenancy/forms/bulk_import.py | 12 +- netbox/tenancy/forms/filtersets.py | 12 +- netbox/tenancy/forms/models.py | 12 +- netbox/virtualization/forms/bulk_edit.py | 12 +- netbox/virtualization/forms/bulk_import.py | 12 +- netbox/virtualization/forms/filtersets.py | 13 ++- netbox/virtualization/forms/models.py | 12 +- netbox/wireless/forms/bulk_edit.py | 8 +- netbox/wireless/forms/bulk_import.py | 8 +- netbox/wireless/forms/filtersets.py | 8 +- netbox/wireless/forms/models.py | 8 +- 32 files changed, 380 insertions(+), 366 deletions(-) create mode 100644 docs/plugins/development/forms.md rename netbox/netbox/{forms.py => forms/__init__.py} (98%) create mode 100644 netbox/netbox/forms/base.py diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md new file mode 100644 index 000000000..5af178194 --- /dev/null +++ b/docs/plugins/development/forms.md @@ -0,0 +1,10 @@ +# Forms + +NetBox provides several base form classes for use by plugins. These are documented below. + +* `NetBoxModelForm` +* `NetBoxModelCSVForm` +* `NetBoxModelBulkEditForm` +* `NetBoxModelFilterSetForm` + +### TODO: Include forms reference diff --git a/mkdocs.yml b/mkdocs.yml index 3b1e52f50..004f21c5e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -105,6 +105,7 @@ nav: - Models: 'plugins/development/models.md' - Views: 'plugins/development/views.md' - Tables: 'plugins/development/tables.md' + - Forms: 'plugins/development/forms.md' - Filter Sets: 'plugins/development/filtersets.md' - REST API: 'plugins/development/rest-api.md' - Background Tasks: 'plugins/development/background-tasks.md' diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index af6bca91f..3e54cf711 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from circuits.choices import CircuitStatusChoices from circuits.models import * -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect @@ -14,7 +14,7 @@ __all__ = ( ) -class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ProviderBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput @@ -53,7 +53,7 @@ class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ProviderNetwork.objects.all(), widget=forms.MultipleHiddenInput @@ -81,7 +81,7 @@ class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor ] -class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CircuitType.objects.all(), widget=forms.MultipleHiddenInput @@ -95,7 +95,7 @@ class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class CircuitBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index fe1b927e5..6da79f75c 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,6 +1,6 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * -from extras.forms import CustomFieldModelCSVForm +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField @@ -12,7 +12,7 @@ __all__ = ( ) -class ProviderCSVForm(CustomFieldModelCSVForm): +class ProviderCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -22,7 +22,7 @@ class ProviderCSVForm(CustomFieldModelCSVForm): ) -class ProviderNetworkCSVForm(CustomFieldModelCSVForm): +class ProviderNetworkCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', @@ -36,7 +36,7 @@ class ProviderNetworkCSVForm(CustomFieldModelCSVForm): ] -class CircuitTypeCSVForm(CustomFieldModelCSVForm): +class CircuitTypeCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -47,7 +47,7 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm): } -class CircuitCSVForm(CustomFieldModelCSVForm): +class CircuitCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index f5ff65088..18f914b58 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext as _ from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.models import Region, Site, SiteGroup -from extras.forms import CustomFieldModelFilterForm +from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField @@ -16,7 +16,7 @@ __all__ = ( ) -class ProviderFilterForm(CustomFieldModelFilterForm): +class ProviderFilterForm(NetBoxModelFilterSetForm): model = Provider field_groups = [ ['q', 'tag'], @@ -49,7 +49,7 @@ class ProviderFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class ProviderNetworkFilterForm(CustomFieldModelFilterForm): +class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork field_groups = ( ('q', 'tag'), @@ -67,12 +67,12 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class CircuitTypeFilterForm(CustomFieldModelFilterForm): +class CircuitTypeFilterForm(NetBoxModelFilterSetForm): model = CircuitType tag = TagFilterField(model) -class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Circuit field_groups = [ ['q', 'tag'], diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index f67114828..bf5d92e85 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -2,8 +2,8 @@ from django import forms from circuits.models import * from dcim.models import Region, Site, SiteGroup -from extras.forms import CustomFieldModelForm from extras.models import Tag +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -19,7 +19,7 @@ __all__ = ( ) -class ProviderForm(CustomFieldModelForm): +class ProviderForm(NetBoxModelForm): slug = SlugField() comments = CommentField() tags = DynamicModelMultipleChoiceField( @@ -53,7 +53,7 @@ class ProviderForm(CustomFieldModelForm): } -class ProviderNetworkForm(CustomFieldModelForm): +class ProviderNetworkForm(NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all() ) @@ -73,7 +73,7 @@ class ProviderNetworkForm(CustomFieldModelForm): ) -class CircuitTypeForm(CustomFieldModelForm): +class CircuitTypeForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -87,7 +87,7 @@ class CircuitTypeForm(CustomFieldModelForm): ] -class CircuitForm(TenancyForm, CustomFieldModelForm): +class CircuitForm(TenancyForm, NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all() ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 3d73ada47..f5eb179fa 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,8 +6,8 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import ASN, VLAN, VRF +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, @@ -57,7 +57,7 @@ __all__ = ( ) -class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RegionBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Region.objects.all(), widget=forms.MultipleHiddenInput @@ -75,7 +75,7 @@ class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=SiteGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -93,7 +93,7 @@ class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class SiteBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Site.objects.all(), widget=forms.MultipleHiddenInput @@ -137,7 +137,7 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class LocationBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Location.objects.all(), widget=forms.MultipleHiddenInput @@ -166,7 +166,7 @@ class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'tenant', 'description'] -class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RackRoleBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackRole.objects.all(), widget=forms.MultipleHiddenInput @@ -183,7 +183,7 @@ class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['color', 'description'] -class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RackBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput @@ -283,7 +283,7 @@ class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RackReservationBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput() @@ -308,7 +308,7 @@ class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor nullable_fields = [] -class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Manufacturer.objects.all(), widget=forms.MultipleHiddenInput @@ -322,7 +322,7 @@ class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput() @@ -353,7 +353,7 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['part_number', 'airflow'] -class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ModuleType.objects.all(), widget=forms.MultipleHiddenInput() @@ -370,7 +370,7 @@ class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['part_number'] -class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceRole.objects.all(), widget=forms.MultipleHiddenInput @@ -392,7 +392,7 @@ class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['color', 'description'] -class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class PlatformBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Platform.objects.all(), widget=forms.MultipleHiddenInput @@ -415,7 +415,7 @@ class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['manufacturer', 'napalm_driver', 'description'] -class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class DeviceBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -476,7 +476,7 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ModuleBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Module.objects.all(), widget=forms.MultipleHiddenInput() @@ -502,7 +502,7 @@ class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['serial'] -class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class CableBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cable.objects.all(), widget=forms.MultipleHiddenInput @@ -558,7 +558,7 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): }) -class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VirtualChassis.objects.all(), widget=forms.MultipleHiddenInput() @@ -572,7 +572,7 @@ class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm nullable_fields = ['domain'] -class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerPanel.objects.all(), widget=forms.MultipleHiddenInput @@ -611,7 +611,7 @@ class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['location'] -class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput @@ -939,8 +939,7 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm): class ConsolePortBulkEditForm( form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsolePort.objects.all(), @@ -957,8 +956,7 @@ class ConsolePortBulkEditForm( class ConsoleServerPortBulkEditForm( form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), @@ -975,8 +973,7 @@ class ConsoleServerPortBulkEditForm( class PowerPortBulkEditForm( form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerPort.objects.all(), @@ -993,8 +990,7 @@ class PowerPortBulkEditForm( class PowerOutletBulkEditForm( form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerOutlet.objects.all(), @@ -1031,8 +1027,7 @@ class InterfaceBulkEditForm( 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ]), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), @@ -1154,8 +1149,7 @@ class InterfaceBulkEditForm( class FrontPortBulkEditForm( form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=FrontPort.objects.all(), @@ -1168,8 +1162,7 @@ class FrontPortBulkEditForm( class RearPortBulkEditForm( form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), @@ -1182,8 +1175,7 @@ class RearPortBulkEditForm( class ModuleBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ModuleBay.objects.all(), @@ -1196,8 +1188,7 @@ class ModuleBayBulkEditForm( class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=DeviceBay.objects.all(), @@ -1210,8 +1201,7 @@ class DeviceBayBulkEditForm( class InventoryItemBulkEditForm( form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=InventoryItem.objects.all(), @@ -1234,7 +1224,7 @@ class InventoryItemBulkEditForm( # Device component roles # -class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 1aec329eb..3974c4d54 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -7,8 +7,8 @@ from django.utils.safestring import mark_safe from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelCSVForm from ipam.models import VRF +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster @@ -46,7 +46,7 @@ __all__ = ( ) -class RegionCSVForm(CustomFieldModelCSVForm): +class RegionCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, @@ -59,7 +59,7 @@ class RegionCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteGroupCSVForm(CustomFieldModelCSVForm): +class SiteGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -72,7 +72,7 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteCSVForm(CustomFieldModelCSVForm): +class SiteCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, help_text='Operational status' @@ -109,7 +109,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): } -class LocationCSVForm(CustomFieldModelCSVForm): +class LocationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -136,7 +136,7 @@ class LocationCSVForm(CustomFieldModelCSVForm): fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') -class RackRoleCSVForm(CustomFieldModelCSVForm): +class RackRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -147,7 +147,7 @@ class RackRoleCSVForm(CustomFieldModelCSVForm): } -class RackCSVForm(CustomFieldModelCSVForm): +class RackCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name' @@ -205,7 +205,7 @@ class RackCSVForm(CustomFieldModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class RackReservationCSVForm(CustomFieldModelCSVForm): +class RackReservationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -255,14 +255,14 @@ class RackReservationCSVForm(CustomFieldModelCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ManufacturerCSVForm(CustomFieldModelCSVForm): +class ManufacturerCSVForm(NetBoxModelCSVForm): class Meta: model = Manufacturer fields = ('name', 'slug', 'description') -class DeviceRoleCSVForm(CustomFieldModelCSVForm): +class DeviceRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -273,7 +273,7 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm): } -class PlatformCSVForm(CustomFieldModelCSVForm): +class PlatformCSVForm(NetBoxModelCSVForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), @@ -287,7 +287,7 @@ class PlatformCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') -class BaseDeviceCSVForm(CustomFieldModelCSVForm): +class BaseDeviceCSVForm(NetBoxModelCSVForm): device_role = CSVModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -403,7 +403,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ModuleCSVForm(CustomFieldModelCSVForm): +class ModuleCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -478,7 +478,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): # Device components # -class ConsolePortCSVForm(CustomFieldModelCSVForm): +class ConsolePortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -501,7 +501,7 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') -class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): +class ConsoleServerPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -524,7 +524,7 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') -class PowerPortCSVForm(CustomFieldModelCSVForm): +class PowerPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -542,7 +542,7 @@ class PowerPortCSVForm(CustomFieldModelCSVForm): ) -class PowerOutletCSVForm(CustomFieldModelCSVForm): +class PowerOutletCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -591,7 +591,7 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm): self.fields['power_port'].queryset = PowerPort.objects.none() -class InterfaceCSVForm(CustomFieldModelCSVForm): +class InterfaceCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -655,7 +655,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): return self.cleaned_data['enabled'] -class FrontPortCSVForm(CustomFieldModelCSVForm): +class FrontPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -703,7 +703,7 @@ class FrontPortCSVForm(CustomFieldModelCSVForm): self.fields['rear_port'].queryset = RearPort.objects.none() -class RearPortCSVForm(CustomFieldModelCSVForm): +class RearPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -721,7 +721,7 @@ class RearPortCSVForm(CustomFieldModelCSVForm): } -class ModuleBayCSVForm(CustomFieldModelCSVForm): +class ModuleBayCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -732,7 +732,7 @@ class ModuleBayCSVForm(CustomFieldModelCSVForm): fields = ('device', 'name', 'label', 'position', 'description') -class DeviceBayCSVForm(CustomFieldModelCSVForm): +class DeviceBayCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -778,7 +778,7 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm): self.fields['installed_device'].queryset = Interface.objects.none() -class InventoryItemCSVForm(CustomFieldModelCSVForm): +class InventoryItemCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -827,7 +827,7 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): # Device component roles # -class InventoryItemRoleCSVForm(CustomFieldModelCSVForm): +class InventoryItemRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -842,7 +842,7 @@ class InventoryItemRoleCSVForm(CustomFieldModelCSVForm): # Cables # -class CableCSVForm(CustomFieldModelCSVForm): +class CableCSVForm(NetBoxModelCSVForm): # Termination A side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -947,7 +947,7 @@ class CableCSVForm(CustomFieldModelCSVForm): # Virtual chassis # -class VirtualChassisCSVForm(CustomFieldModelCSVForm): +class VirtualChassisCSVForm(NetBoxModelCSVForm): master = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -964,7 +964,7 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm): # Power # -class PowerPanelCSVForm(CustomFieldModelCSVForm): +class PowerPanelCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -990,7 +990,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class PowerFeedCSVForm(CustomFieldModelCSVForm): +class PowerFeedCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 6a7a09023..7ae6a898a 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,7 +1,7 @@ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * -from extras.forms import CustomFieldModelForm from extras.models import Tag +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect @@ -18,7 +18,7 @@ __all__ = ( ) -class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm): """ Base form for connecting a Cable to a Device component """ @@ -171,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm): termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', @@ -229,7 +229,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): return getattr(self.cleaned_data['termination_b_id'], 'pk', None) -class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm): termination_b_region = DynamicModelChoiceField( queryset=Region.objects.all(), label='Region', diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 8868cdf78..e9aa3ec3f 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -5,8 +5,9 @@ from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm +from extras.forms import LocalConfigContextFilterForm from ipam.models import ASN, VRF +from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, @@ -52,7 +53,7 @@ __all__ = ( ) -class DeviceComponentFilterForm(CustomFieldModelFilterForm): +class DeviceComponentFilterForm(NetBoxModelFilterSetForm): name = forms.CharField( required=False ) @@ -103,7 +104,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): ) -class RegionFilterForm(CustomFieldModelFilterForm): +class RegionFilterForm(NetBoxModelFilterSetForm): model = Region parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -113,7 +114,7 @@ class RegionFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class SiteGroupFilterForm(CustomFieldModelFilterForm): +class SiteGroupFilterForm(NetBoxModelFilterSetForm): model = SiteGroup parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -123,7 +124,7 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Site field_groups = [ ['q', 'tag'], @@ -154,7 +155,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class LocationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Location field_groups = [ ['q', 'tag'], @@ -192,12 +193,12 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class RackRoleFilterForm(CustomFieldModelFilterForm): +class RackRoleFilterForm(NetBoxModelFilterSetForm): model = RackRole tag = TagFilterField(model) -class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Rack field_groups = [ ['q', 'tag'], @@ -270,7 +271,7 @@ class RackElevationFilterForm(RackFilterForm): ) -class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation field_groups = [ ['q', 'tag'], @@ -308,12 +309,12 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class ManufacturerFilterForm(CustomFieldModelFilterForm): +class ManufacturerFilterForm(NetBoxModelFilterSetForm): model = Manufacturer tag = TagFilterField(model) -class DeviceTypeFilterForm(CustomFieldModelFilterForm): +class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType field_groups = [ ['q', 'tag'], @@ -383,7 +384,7 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class ModuleTypeFilterForm(CustomFieldModelFilterForm): +class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType field_groups = [ ['q', 'tag'], @@ -444,12 +445,12 @@ class ModuleTypeFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class DeviceRoleFilterForm(CustomFieldModelFilterForm): +class DeviceRoleFilterForm(NetBoxModelFilterSetForm): model = DeviceRole tag = TagFilterField(model) -class PlatformFilterForm(CustomFieldModelFilterForm): +class PlatformFilterForm(NetBoxModelFilterSetForm): model = Platform manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -459,7 +460,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): +class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Device field_groups = [ ['q', 'tag'], @@ -613,7 +614,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi tag = TagFilterField(model) -class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): +class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module field_groups = [ ['q', 'tag'], @@ -644,7 +645,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi tag = TagFilterField(model) -class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis field_groups = [ ['q', 'tag'], @@ -673,7 +674,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable field_groups = [ ['q', 'tag'], @@ -736,7 +737,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class PowerPanelFilterForm(CustomFieldModelFilterForm): +class PowerPanelFilterForm(NetBoxModelFilterSetForm): model = PowerPanel field_groups = ( ('q', 'tag'), @@ -773,7 +774,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class PowerFeedFilterForm(CustomFieldModelFilterForm): +class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed field_groups = [ ['q', 'tag'], @@ -1103,7 +1104,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): # Device component roles # -class InventoryItemRoleFilterForm(CustomFieldModelFilterForm): +class InventoryItemRoleFilterForm(NetBoxModelFilterSetForm): model = InventoryItemRole tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 378a567fc..80e785940 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -7,9 +7,9 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, @@ -72,7 +72,7 @@ Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) """ -class RegionForm(CustomFieldModelForm): +class RegionForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -90,7 +90,7 @@ class RegionForm(CustomFieldModelForm): ) -class SiteGroupForm(CustomFieldModelForm): +class SiteGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False @@ -108,7 +108,7 @@ class SiteGroupForm(CustomFieldModelForm): ) -class SiteForm(TenancyForm, CustomFieldModelForm): +class SiteForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -173,7 +173,7 @@ class SiteForm(TenancyForm, CustomFieldModelForm): } -class LocationForm(TenancyForm, CustomFieldModelForm): +class LocationForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -221,7 +221,7 @@ class LocationForm(TenancyForm, CustomFieldModelForm): ) -class RackRoleForm(CustomFieldModelForm): +class RackRoleForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -235,7 +235,7 @@ class RackRoleForm(CustomFieldModelForm): ] -class RackForm(TenancyForm, CustomFieldModelForm): +class RackForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -295,7 +295,7 @@ class RackForm(TenancyForm, CustomFieldModelForm): } -class RackReservationForm(TenancyForm, CustomFieldModelForm): +class RackReservationForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -359,7 +359,7 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): ) -class ManufacturerForm(CustomFieldModelForm): +class ManufacturerForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -373,7 +373,7 @@ class ManufacturerForm(CustomFieldModelForm): ] -class DeviceTypeForm(CustomFieldModelForm): +class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all() ) @@ -412,7 +412,7 @@ class DeviceTypeForm(CustomFieldModelForm): } -class ModuleTypeForm(CustomFieldModelForm): +class ModuleTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all() ) @@ -429,7 +429,7 @@ class ModuleTypeForm(CustomFieldModelForm): ] -class DeviceRoleForm(CustomFieldModelForm): +class DeviceRoleForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -443,7 +443,7 @@ class DeviceRoleForm(CustomFieldModelForm): ] -class PlatformForm(CustomFieldModelForm): +class PlatformForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -466,7 +466,7 @@ class PlatformForm(CustomFieldModelForm): } -class DeviceForm(TenancyForm, CustomFieldModelForm): +class DeviceForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -648,7 +648,7 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): self.fields['position'].widget.choices = [(position, f'U{position}')] -class ModuleForm(CustomFieldModelForm): +class ModuleForm(NetBoxModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, @@ -688,7 +688,7 @@ class ModuleForm(CustomFieldModelForm): ] -class CableForm(TenancyForm, CustomFieldModelForm): +class CableForm(TenancyForm, NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -711,7 +711,7 @@ class CableForm(TenancyForm, CustomFieldModelForm): } -class PowerPanelForm(CustomFieldModelForm): +class PowerPanelForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -755,7 +755,7 @@ class PowerPanelForm(CustomFieldModelForm): ) -class PowerFeedForm(CustomFieldModelForm): +class PowerFeedForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -823,7 +823,7 @@ class PowerFeedForm(CustomFieldModelForm): # Virtual chassis # -class VirtualChassisForm(CustomFieldModelForm): +class VirtualChassisForm(NetBoxModelForm): master = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, @@ -1120,7 +1120,7 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): # Device components # -class ConsolePortForm(CustomFieldModelForm): +class ConsolePortForm(NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1138,7 +1138,7 @@ class ConsolePortForm(CustomFieldModelForm): } -class ConsoleServerPortForm(CustomFieldModelForm): +class ConsoleServerPortForm(NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1156,7 +1156,7 @@ class ConsoleServerPortForm(CustomFieldModelForm): } -class PowerPortForm(CustomFieldModelForm): +class PowerPortForm(NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1174,7 +1174,7 @@ class PowerPortForm(CustomFieldModelForm): } -class PowerOutletForm(CustomFieldModelForm): +class PowerOutletForm(NetBoxModelForm): power_port = DynamicModelChoiceField( queryset=PowerPort.objects.all(), required=False, @@ -1199,7 +1199,7 @@ class PowerOutletForm(CustomFieldModelForm): } -class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): +class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1308,7 +1308,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): } -class FrontPortForm(CustomFieldModelForm): +class FrontPortForm(NetBoxModelForm): rear_port = DynamicModelChoiceField( queryset=RearPort.objects.all(), query_params={ @@ -1332,7 +1332,7 @@ class FrontPortForm(CustomFieldModelForm): } -class RearPortForm(CustomFieldModelForm): +class RearPortForm(NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1349,7 +1349,7 @@ class RearPortForm(CustomFieldModelForm): } -class ModuleBayForm(CustomFieldModelForm): +class ModuleBayForm(NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1365,7 +1365,7 @@ class ModuleBayForm(CustomFieldModelForm): } -class DeviceBayForm(CustomFieldModelForm): +class DeviceBayForm(NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1401,7 +1401,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class InventoryItemForm(CustomFieldModelForm): +class InventoryItemForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, @@ -1451,7 +1451,7 @@ class InventoryItemForm(CustomFieldModelForm): # Device component roles # -class InventoryItemRoleForm(CustomFieldModelForm): +class InventoryItemRoleForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 1fea886ea..73bb621fc 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,8 +1,8 @@ from django import forms from dcim.models import * -from extras.forms import CustomFieldModelForm from extras.models import Tag +from netbox.forms import NetBoxModelForm from utilities.forms import ( BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, ) @@ -149,7 +149,7 @@ class FrontPortCreateForm(DeviceComponentCreateForm): } -class VirtualChassisCreateForm(CustomFieldModelForm): +class VirtualChassisCreateForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 8912d0365..c3a44f47b 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,16 +1,8 @@ -from django import forms from django.contrib.contenttypes.models import ContentType -from django.db.models import Q -from extras.choices import * from extras.models import * -from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm __all__ = ( - 'CustomFieldModelCSVForm', - 'CustomFieldModelBulkEditForm', - 'CustomFieldModelFilterForm', - 'CustomFieldModelForm', 'CustomFieldsMixin', ) @@ -50,76 +42,3 @@ class CustomFieldsMixin: # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield - - -class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): - """ - Extend ModelForm to include custom field support. - """ - def _get_content_type(self): - return ContentType.objects.get_for_model(self._meta.model) - - def _get_form_field(self, customfield): - if self.instance.pk: - form_field = customfield.to_form_field(set_initial=False) - form_field.initial = self.instance.custom_field_data.get(customfield.name, None) - return form_field - - return customfield.to_form_field() - - def clean(self): - - # Save custom field data on instance - for cf_name, customfield in self.custom_fields.items(): - key = cf_name[3:] # Strip "cf_" from field name - value = self.cleaned_data.get(cf_name) - - # Convert "empty" values to null - if value in self.fields[cf_name].empty_values: - self.instance.custom_field_data[key] = None - else: - self.instance.custom_field_data[key] = customfield.serialize(value) - - return super().clean() - - -class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): - - def _get_form_field(self, customfield): - return customfield.to_form_field(for_csv_import=True) - - -class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm): - - def _get_form_field(self, customfield): - return customfield.to_form_field(set_initial=False, enforce_required=False) - - def _append_customfield_fields(self): - """ - Append form fields for all CustomFields assigned to this object type. - """ - for customfield in self._get_custom_fields(self._get_content_type()): - # Annotate non-required custom fields as nullable - if not customfield.required: - self.nullable_fields.append(customfield.name) - - self.fields[customfield.name] = self._get_form_field(customfield) - - # Annotate the field in the list of CustomField form fields - self.custom_fields[customfield.name] = customfield - - -class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form): - q = forms.CharField( - required=False, - label='Search' - ) - - def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type).exclude( - Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | - Q(type=CustomFieldTypeChoices.TYPE_JSON) - ) - - def _get_form_field(self, customfield): - return customfield.to_form_field(set_initial=False, enforce_required=False) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 9aac8454b..5c29a8381 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -13,7 +13,6 @@ from utilities.forms import ( from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( - 'AddRemoveTagsForm', 'ConfigContextForm', 'CustomFieldForm', 'CustomLinkForm', @@ -134,22 +133,6 @@ class TagForm(BootstrapMixin, forms.ModelForm): ) -class AddRemoveTagsForm(forms.Form): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Add add/remove tags fields - self.fields['add_tags'] = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - self.fields['remove_tags'] = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class ConfigContextForm(BootstrapMixin, forms.ModelForm): regions = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 308a467d1..3da03dd41 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,11 +1,11 @@ from django import forms from dcim.models import Region, Site, SiteGroup -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.choices import * from ipam.constants import * from ipam.models import * from ipam.models import ASN +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect, @@ -30,7 +30,7 @@ __all__ = ( ) -class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VRFBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput() @@ -55,7 +55,7 @@ class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RouteTarget.objects.all(), widget=forms.MultipleHiddenInput() @@ -75,7 +75,7 @@ class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RIRBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RIR.objects.all(), widget=forms.MultipleHiddenInput @@ -93,7 +93,7 @@ class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['is_private', 'description'] -class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ASNBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ASN.objects.all(), widget=forms.MultipleHiddenInput() @@ -125,7 +125,7 @@ class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): } -class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class AggregateBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput() @@ -156,7 +156,7 @@ class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): } -class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class RoleBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Role.objects.all(), widget=forms.MultipleHiddenInput @@ -173,7 +173,7 @@ class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class PrefixBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() @@ -238,7 +238,7 @@ class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class IPRangeBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=IPRange.objects.all(), widget=forms.MultipleHiddenInput() @@ -272,7 +272,7 @@ class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class IPAddressBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput() @@ -317,7 +317,7 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=FHRPGroup.objects.all(), widget=forms.MultipleHiddenInput() @@ -352,7 +352,7 @@ class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['auth_type', 'auth_key', 'description'] -class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -382,7 +382,7 @@ class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['site', 'description'] -class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VLANBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() @@ -434,7 +434,7 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ServiceTemplate.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 1ae977fe5..365f82858 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -2,10 +2,10 @@ from django import forms from django.contrib.contenttypes.models import ContentType from dcim.models import Device, Interface, Site -from extras.forms import CustomFieldModelCSVForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from virtualization.models import VirtualMachine, VMInterface @@ -28,7 +28,7 @@ __all__ = ( ) -class VRFCSVForm(CustomFieldModelCSVForm): +class VRFCSVForm(NetBoxModelCSVForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -41,7 +41,7 @@ class VRFCSVForm(CustomFieldModelCSVForm): fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description') -class RouteTargetCSVForm(CustomFieldModelCSVForm): +class RouteTargetCSVForm(NetBoxModelCSVForm): tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -54,7 +54,7 @@ class RouteTargetCSVForm(CustomFieldModelCSVForm): fields = ('name', 'description', 'tenant') -class RIRCSVForm(CustomFieldModelCSVForm): +class RIRCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -65,7 +65,7 @@ class RIRCSVForm(CustomFieldModelCSVForm): } -class AggregateCSVForm(CustomFieldModelCSVForm): +class AggregateCSVForm(NetBoxModelCSVForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -83,7 +83,7 @@ class AggregateCSVForm(CustomFieldModelCSVForm): fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') -class ASNCSVForm(CustomFieldModelCSVForm): +class ASNCSVForm(NetBoxModelCSVForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -102,7 +102,7 @@ class ASNCSVForm(CustomFieldModelCSVForm): help_texts = {} -class RoleCSVForm(CustomFieldModelCSVForm): +class RoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -110,7 +110,7 @@ class RoleCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'weight', 'description') -class PrefixCSVForm(CustomFieldModelCSVForm): +class PrefixCSVForm(NetBoxModelCSVForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', @@ -174,7 +174,7 @@ class PrefixCSVForm(CustomFieldModelCSVForm): self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) -class IPRangeCSVForm(CustomFieldModelCSVForm): +class IPRangeCSVForm(NetBoxModelCSVForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', @@ -205,7 +205,7 @@ class IPRangeCSVForm(CustomFieldModelCSVForm): ) -class IPAddressCSVForm(CustomFieldModelCSVForm): +class IPAddressCSVForm(NetBoxModelCSVForm): vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', @@ -312,7 +312,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): return ipaddress -class FHRPGroupCSVForm(CustomFieldModelCSVForm): +class FHRPGroupCSVForm(NetBoxModelCSVForm): protocol = CSVChoiceField( choices=FHRPGroupProtocolChoices ) @@ -326,7 +326,7 @@ class FHRPGroupCSVForm(CustomFieldModelCSVForm): fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description') -class VLANGroupCSVForm(CustomFieldModelCSVForm): +class VLANGroupCSVForm(NetBoxModelCSVForm): slug = SlugField() scope_type = CSVContentTypeField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), @@ -354,7 +354,7 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm): } -class VLANCSVForm(CustomFieldModelCSVForm): +class VLANCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, @@ -393,7 +393,7 @@ class VLANCSVForm(CustomFieldModelCSVForm): } -class ServiceTemplateCSVForm(CustomFieldModelCSVForm): +class ServiceTemplateCSVForm(NetBoxModelCSVForm): protocol = CSVChoiceField( choices=ServiceProtocolChoices, help_text='IP protocol' @@ -404,7 +404,7 @@ class ServiceTemplateCSVForm(CustomFieldModelCSVForm): fields = ('name', 'protocol', 'ports', 'description') -class ServiceCSVForm(CustomFieldModelCSVForm): +class ServiceCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 9bfb1df10..4301a1810 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -2,11 +2,11 @@ from django import forms from django.utils.translation import gettext as _ from dcim.models import Location, Rack, Region, Site, SiteGroup -from extras.forms import CustomFieldModelFilterForm from ipam.choices import * from ipam.constants import * from ipam.models import * from ipam.models import ASN +from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, @@ -39,7 +39,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ ]) -class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF field_groups = [ ['q', 'tag'], @@ -59,7 +59,7 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget field_groups = [ ['q', 'tag'], @@ -79,7 +79,7 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class RIRFilterForm(CustomFieldModelFilterForm): +class RIRFilterForm(NetBoxModelFilterSetForm): model = RIR is_private = forms.NullBooleanField( required=False, @@ -91,7 +91,7 @@ class RIRFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate field_groups = [ ['q', 'tag'], @@ -112,7 +112,7 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN field_groups = [ ['q'], @@ -132,12 +132,12 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): ) -class RoleFilterForm(CustomFieldModelFilterForm): +class RoleFilterForm(NetBoxModelFilterSetForm): model = Role tag = TagFilterField(model) -class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix field_groups = [ ['q', 'tag'], @@ -228,7 +228,7 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange field_groups = [ ['q', 'tag'], @@ -261,7 +261,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress field_groups = [ ['q', 'tag'], @@ -321,7 +321,7 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class FHRPGroupFilterForm(CustomFieldModelFilterForm): +class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup field_groups = ( ('q', 'tag'), @@ -351,7 +351,7 @@ class FHRPGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class VLANGroupFilterForm(CustomFieldModelFilterForm): +class VLANGroupFilterForm(NetBoxModelFilterSetForm): field_groups = [ ['q', 'tag'], ['region', 'sitegroup', 'site', 'location', 'rack'], @@ -394,7 +394,7 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN field_groups = [ ['q', 'tag'], @@ -448,7 +448,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class ServiceTemplateFilterForm(CustomFieldModelFilterForm): +class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate field_groups = ( ('q', 'tag'), diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 34c67773f..e86fe1dab 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -2,13 +2,13 @@ from django import forms from django.contrib.contenttypes.models import ContentType from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup -from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * from ipam.models import ASN +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.exceptions import PermissionsViolation from utilities.forms import ( @@ -39,7 +39,7 @@ __all__ = ( ) -class VRFForm(TenancyForm, CustomFieldModelForm): +class VRFForm(TenancyForm, NetBoxModelForm): import_targets = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), required=False @@ -72,7 +72,7 @@ class VRFForm(TenancyForm, CustomFieldModelForm): } -class RouteTargetForm(TenancyForm, CustomFieldModelForm): +class RouteTargetForm(TenancyForm, NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -89,7 +89,7 @@ class RouteTargetForm(TenancyForm, CustomFieldModelForm): ) -class RIRForm(CustomFieldModelForm): +class RIRForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -103,7 +103,7 @@ class RIRForm(CustomFieldModelForm): ] -class AggregateForm(TenancyForm, CustomFieldModelForm): +class AggregateForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label='RIR' @@ -131,7 +131,7 @@ class AggregateForm(TenancyForm, CustomFieldModelForm): } -class ASNForm(TenancyForm, CustomFieldModelForm): +class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label='RIR', @@ -175,7 +175,7 @@ class ASNForm(TenancyForm, CustomFieldModelForm): return instance -class RoleForm(CustomFieldModelForm): +class RoleForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -189,7 +189,7 @@ class RoleForm(CustomFieldModelForm): ] -class PrefixForm(TenancyForm, CustomFieldModelForm): +class PrefixForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -264,7 +264,7 @@ class PrefixForm(TenancyForm, CustomFieldModelForm): } -class IPRangeForm(TenancyForm, CustomFieldModelForm): +class IPRangeForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -293,7 +293,7 @@ class IPRangeForm(TenancyForm, CustomFieldModelForm): } -class IPAddressForm(TenancyForm, CustomFieldModelForm): +class IPAddressForm(TenancyForm, NetBoxModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, @@ -506,7 +506,7 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm): return ipaddress -class IPAddressBulkAddForm(TenancyForm, CustomFieldModelForm): +class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -540,7 +540,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): ) -class FHRPGroupForm(CustomFieldModelForm): +class FHRPGroupForm(NetBoxModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -629,7 +629,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk) -class VLANGroupForm(CustomFieldModelForm): +class VLANGroupForm(NetBoxModelForm): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False @@ -736,7 +736,7 @@ class VLANGroupForm(CustomFieldModelForm): self.instance.scope_id = None -class VLANForm(TenancyForm, CustomFieldModelForm): +class VLANForm(TenancyForm, NetBoxModelForm): # VLANGroup assignment fields scope_type = forms.ChoiceField( choices=( @@ -817,7 +817,7 @@ class VLANForm(TenancyForm, CustomFieldModelForm): } -class ServiceTemplateForm(CustomFieldModelForm): +class ServiceTemplateForm(NetBoxModelForm): ports = NumericArrayField( base_field=forms.IntegerField( min_value=SERVICE_PORT_MIN, @@ -838,7 +838,7 @@ class ServiceTemplateForm(CustomFieldModelForm): } -class ServiceForm(CustomFieldModelForm): +class ServiceForm(NetBoxModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms/__init__.py similarity index 98% rename from netbox/netbox/forms.py rename to netbox/netbox/forms/__init__.py index b5d68c1fc..9984a4461 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms/__init__.py @@ -1,6 +1,7 @@ from django import forms from utilities.forms import BootstrapMixin +from .base import * OBJ_TYPE_CHOICES = ( ('', 'All Objects'), diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py new file mode 100644 index 000000000..601b0062a --- /dev/null +++ b/netbox/netbox/forms/base.py @@ -0,0 +1,108 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices +from extras.forms.customfields import CustomFieldsMixin +from extras.models import CustomField, Tag +from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm +from utilities.forms.fields import DynamicModelMultipleChoiceField + +__all__ = ( + 'NetBoxModelForm', + 'NetBoxModelCSVForm', + 'NetBoxModelBulkEditForm', + 'NetBoxModelFilterSetForm', +) + + +class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): + """ + Base form for creating & editing NetBox models. Adds support for custom fields. + """ + def _get_content_type(self): + return ContentType.objects.get_for_model(self._meta.model) + + def _get_form_field(self, customfield): + if self.instance.pk: + form_field = customfield.to_form_field(set_initial=False) + form_field.initial = self.instance.custom_field_data.get(customfield.name, None) + return form_field + + return customfield.to_form_field() + + def clean(self): + + # Save custom field data on instance + for cf_name, customfield in self.custom_fields.items(): + key = cf_name[3:] # Strip "cf_" from field name + value = self.cleaned_data.get(cf_name) + + # Convert "empty" values to null + if value in self.fields[cf_name].empty_values: + self.instance.custom_field_data[key] = None + else: + self.instance.custom_field_data[key] = customfield.serialize(value) + + return super().clean() + + +class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): + """ + Base form for creating a NetBox objects from CSV data. Used for bulk importing. + """ + def _get_form_field(self, customfield): + return customfield.to_form_field(for_csv_import=True) + + +class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm): + """ + Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom + fields and adding/removing tags. + """ + add_tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + remove_tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) + + def _append_customfield_fields(self): + """ + Append form fields for all CustomFields assigned to this object type. + """ + for customfield in self._get_custom_fields(self._get_content_type()): + # Annotate non-required custom fields as nullable + if not customfield.required: + self.nullable_fields.append(customfield.name) + + self.fields[customfield.name] = self._get_form_field(customfield) + + # Annotate the field in the list of CustomField form fields + self.custom_fields[customfield.name] = customfield + + +class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): + """ + Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. + + The corresponding FilterSet *must* provide a `q` filter. + """ + q = forms.CharField( + required=False, + label='Search' + ) + + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type).exclude( + Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | + Q(type=CustomFieldTypeChoices.TYPE_JSON) + ) + + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 9dc1b8ec5..9b14a167b 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -1,6 +1,6 @@ from django import forms -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * from utilities.forms import DynamicModelChoiceField @@ -17,7 +17,7 @@ __all__ = ( # Tenants # -class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=TenantGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -35,7 +35,7 @@ class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class TenantBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput() @@ -55,7 +55,7 @@ class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): # Contacts # -class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -73,7 +73,7 @@ class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactRole.objects.all(), widget=forms.MultipleHiddenInput @@ -87,7 +87,7 @@ class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ContactBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Contact.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 51b863cac..409590c28 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,4 +1,4 @@ -from extras.forms import CustomFieldModelCSVForm +from netbox.forms import NetBoxModelCSVForm from tenancy.models import * from utilities.forms import CSVModelChoiceField, SlugField @@ -15,7 +15,7 @@ __all__ = ( # Tenants # -class TenantGroupCSVForm(CustomFieldModelCSVForm): +class TenantGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -29,7 +29,7 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class TenantCSVForm(CustomFieldModelCSVForm): +class TenantCSVForm(NetBoxModelCSVForm): slug = SlugField() group = CSVModelChoiceField( queryset=TenantGroup.objects.all(), @@ -47,7 +47,7 @@ class TenantCSVForm(CustomFieldModelCSVForm): # Contacts # -class ContactGroupCSVForm(CustomFieldModelCSVForm): +class ContactGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, @@ -61,7 +61,7 @@ class ContactGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class ContactRoleCSVForm(CustomFieldModelCSVForm): +class ContactRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -69,7 +69,7 @@ class ContactRoleCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'description') -class ContactCSVForm(CustomFieldModelCSVForm): +class ContactCSVForm(NetBoxModelCSVForm): group = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 7849e2171..8fb4b50ff 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,6 +1,6 @@ from django.utils.translation import gettext as _ -from extras.forms import CustomFieldModelFilterForm +from netbox.forms import NetBoxModelFilterSetForm from tenancy.models import * from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField @@ -17,7 +17,7 @@ __all__ = ( # Tenants # -class TenantGroupFilterForm(CustomFieldModelFilterForm): +class TenantGroupFilterForm(NetBoxModelFilterSetForm): model = TenantGroup parent_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), @@ -27,7 +27,7 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class TenantFilterForm(CustomFieldModelFilterForm): +class TenantFilterForm(NetBoxModelFilterSetForm): model = Tenant field_groups = ( ('q', 'tag'), @@ -46,7 +46,7 @@ class TenantFilterForm(CustomFieldModelFilterForm): # Contacts # -class ContactGroupFilterForm(CustomFieldModelFilterForm): +class ContactGroupFilterForm(NetBoxModelFilterSetForm): model = ContactGroup parent_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), @@ -56,12 +56,12 @@ class ContactGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class ContactRoleFilterForm(CustomFieldModelFilterForm): +class ContactRoleFilterForm(NetBoxModelFilterSetForm): model = ContactRole tag = TagFilterField(model) -class ContactFilterForm(CustomFieldModelFilterForm): +class ContactFilterForm(NetBoxModelFilterSetForm): model = Contact field_groups = ( ('q', 'tag'), diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 398a44c9b..313b55417 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -1,7 +1,7 @@ from django import forms -from extras.forms import CustomFieldModelForm from extras.models import Tag +from netbox.forms import NetBoxModelForm from tenancy.models import * from utilities.forms import ( BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea, @@ -22,7 +22,7 @@ __all__ = ( # Tenants # -class TenantGroupForm(CustomFieldModelForm): +class TenantGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False @@ -40,7 +40,7 @@ class TenantGroupForm(CustomFieldModelForm): ] -class TenantForm(CustomFieldModelForm): +class TenantForm(NetBoxModelForm): slug = SlugField() group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), @@ -66,7 +66,7 @@ class TenantForm(CustomFieldModelForm): # Contacts # -class ContactGroupForm(CustomFieldModelForm): +class ContactGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False @@ -82,7 +82,7 @@ class ContactGroupForm(CustomFieldModelForm): fields = ('parent', 'name', 'slug', 'description', 'tags') -class ContactRoleForm(CustomFieldModelForm): +class ContactRoleForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -94,7 +94,7 @@ class ContactRoleForm(CustomFieldModelForm): fields = ('name', 'slug', 'description', 'tags') -class ContactForm(CustomFieldModelForm): +class ContactForm(NetBoxModelForm): group = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 1e80e88e5..4232a87cd 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -3,8 +3,8 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, @@ -23,7 +23,7 @@ __all__ = ( ) -class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterType.objects.all(), widget=forms.MultipleHiddenInput @@ -37,7 +37,7 @@ class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -51,7 +51,7 @@ class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ClusterBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput() @@ -95,7 +95,7 @@ class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput() @@ -150,7 +150,7 @@ class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm ] -class VMInterfaceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index bd3279959..cefc2219d 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,6 +1,6 @@ from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site -from extras.forms import CustomFieldModelCSVForm +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from virtualization.choices import * @@ -15,7 +15,7 @@ __all__ = ( ) -class ClusterTypeCSVForm(CustomFieldModelCSVForm): +class ClusterTypeCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -23,7 +23,7 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'description') -class ClusterGroupCSVForm(CustomFieldModelCSVForm): +class ClusterGroupCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -31,7 +31,7 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'description') -class ClusterCSVForm(CustomFieldModelCSVForm): +class ClusterCSVForm(NetBoxModelCSVForm): type = CSVModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', @@ -61,7 +61,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm): fields = ('name', 'type', 'group', 'site', 'comments') -class VirtualMachineCSVForm(CustomFieldModelCSVForm): +class VirtualMachineCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, help_text='Operational status of device' @@ -99,7 +99,7 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm): ) -class VMInterfaceCSVForm(CustomFieldModelCSVForm): +class VMInterfaceCSVForm(NetBoxModelCSVForm): virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), to_field_name='name' diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 9ca8eba6e..292cd661d 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -2,7 +2,8 @@ from django import forms from django.utils.translation import gettext as _ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm +from extras.forms import LocalConfigContextFilterForm +from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -19,17 +20,17 @@ __all__ = ( ) -class ClusterTypeFilterForm(CustomFieldModelFilterForm): +class ClusterTypeFilterForm(NetBoxModelFilterSetForm): model = ClusterType tag = TagFilterField(model) -class ClusterGroupFilterForm(CustomFieldModelFilterForm): +class ClusterGroupFilterForm(NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) -class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cluster field_groups = [ ['q', 'tag'], @@ -71,7 +72,7 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): +class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualMachine field_groups = [ ['q', 'tag'], @@ -151,7 +152,7 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, tag = TagFilterField(model) -class VMInterfaceFilterForm(CustomFieldModelFilterForm): +class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface field_groups = [ ['q', 'tag'], diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 624c9e87f..883fcd363 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -5,9 +5,9 @@ from django.core.exceptions import ValidationError from dcim.forms.common import InterfaceCommonForm from dcim.forms.models import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup -from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import IPAddress, VLAN, VLANGroup +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -26,7 +26,7 @@ __all__ = ( ) -class ClusterTypeForm(CustomFieldModelForm): +class ClusterTypeForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -40,7 +40,7 @@ class ClusterTypeForm(CustomFieldModelForm): ) -class ClusterGroupForm(CustomFieldModelForm): +class ClusterGroupForm(NetBoxModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -54,7 +54,7 @@ class ClusterGroupForm(CustomFieldModelForm): ) -class ClusterForm(TenancyForm, CustomFieldModelForm): +class ClusterForm(TenancyForm, NetBoxModelForm): type = DynamicModelChoiceField( queryset=ClusterType.objects.all() ) @@ -171,7 +171,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): ) -class VirtualMachineForm(TenancyForm, CustomFieldModelForm): +class VirtualMachineForm(TenancyForm, NetBoxModelForm): cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -271,7 +271,7 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): +class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 9d07d09f0..147108d77 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -1,8 +1,8 @@ from django import forms from dcim.choices import LinkStatusChoices -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN +from netbox.forms import NetBoxModelBulkEditForm from utilities.forms import add_blank_choice, DynamicModelChoiceField from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH @@ -15,7 +15,7 @@ __all__ = ( ) -class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=WirelessLANGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -33,7 +33,7 @@ class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFo nullable_fields = ['parent', 'description'] -class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=WirelessLAN.objects.all(), widget=forms.MultipleHiddenInput @@ -72,7 +72,7 @@ class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] -class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=WirelessLink.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index aa79e1fc7..4b8acb385 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,7 +1,7 @@ from dcim.choices import LinkStatusChoices from dcim.models import Interface -from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN +from netbox.forms import NetBoxModelCSVForm from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * @@ -13,7 +13,7 @@ __all__ = ( ) -class WirelessLANGroupCSVForm(CustomFieldModelCSVForm): +class WirelessLANGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -27,7 +27,7 @@ class WirelessLANGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class WirelessLANCSVForm(CustomFieldModelCSVForm): +class WirelessLANCSVForm(NetBoxModelCSVForm): group = CSVModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -56,7 +56,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk') -class WirelessLinkCSVForm(CustomFieldModelCSVForm): +class WirelessLinkCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=LinkStatusChoices, help_text='Connection status' diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 82df93e6c..3c46caf21 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -2,7 +2,7 @@ from django import forms from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices -from extras.forms import CustomFieldModelFilterForm +from netbox.forms import NetBoxModelFilterSetForm from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField from wireless.choices import * from wireless.models import * @@ -14,7 +14,7 @@ __all__ = ( ) -class WirelessLANGroupFilterForm(CustomFieldModelFilterForm): +class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): model = WirelessLANGroup parent_id = DynamicModelMultipleChoiceField( queryset=WirelessLANGroup.objects.all(), @@ -24,7 +24,7 @@ class WirelessLANGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class WirelessLANFilterForm(CustomFieldModelFilterForm): +class WirelessLANFilterForm(NetBoxModelFilterSetForm): model = WirelessLAN field_groups = [ ('q', 'tag'), @@ -56,7 +56,7 @@ class WirelessLANFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class WirelessLinkFilterForm(CustomFieldModelFilterForm): +class WirelessLinkFilterForm(NetBoxModelFilterSetForm): model = WirelessLink ssid = forms.CharField( required=False, diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 7687cb372..30a4a2352 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,7 +1,7 @@ from dcim.models import Device, Interface, Location, Site -from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN +from netbox.forms import NetBoxModelForm from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect from wireless.models import * @@ -12,7 +12,7 @@ __all__ = ( ) -class WirelessLANGroupForm(CustomFieldModelForm): +class WirelessLANGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -30,7 +30,7 @@ class WirelessLANGroupForm(CustomFieldModelForm): ] -class WirelessLANForm(CustomFieldModelForm): +class WirelessLANForm(NetBoxModelForm): group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -61,7 +61,7 @@ class WirelessLANForm(CustomFieldModelForm): } -class WirelessLinkForm(CustomFieldModelForm): +class WirelessLinkForm(NetBoxModelForm): site_a = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, From e4eee1cdfc74519e1494102506655063ac8b0264 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 28 Jan 2022 16:47:54 -0500 Subject: [PATCH 130/271] Clean up nullable fields declaration for bulk edit forms --- netbox/circuits/forms/bulk_edit.py | 14 ++--- netbox/dcim/forms/bulk_edit.py | 78 ++++++++++++------------ netbox/extras/forms/bulk_edit.py | 18 ++---- netbox/ipam/forms/bulk_edit.py | 44 +++++++------ netbox/netbox/forms/base.py | 13 ++-- netbox/tenancy/forms/bulk_edit.py | 12 ++-- netbox/utilities/forms/forms.py | 40 +++++++----- netbox/virtualization/forms/bulk_edit.py | 16 ++--- netbox/wireless/forms/bulk_edit.py | 6 +- 9 files changed, 120 insertions(+), 121 deletions(-) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 3e54cf711..f17df1302 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -48,9 +48,9 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - ] + ) class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): @@ -76,9 +76,9 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'service_id', 'description', 'comments', - ] + ) class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -92,7 +92,7 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = ('description',) class CircuitBulkEditForm(NetBoxModelBulkEditForm): @@ -132,6 +132,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', - ] + ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index f5eb179fa..13e9d945b 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -72,7 +72,7 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['parent', 'description'] + nullable_fields = ('parent', 'description') class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -90,7 +90,7 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['parent', 'description'] + nullable_fields = ('parent', 'description') class SiteBulkEditForm(NetBoxModelBulkEditForm): @@ -132,9 +132,9 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', - ] + ) class LocationBulkEditForm(NetBoxModelBulkEditForm): @@ -163,7 +163,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['parent', 'tenant', 'description'] + nullable_fields = ('parent', 'tenant', 'description') class RackRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -180,7 +180,7 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['color', 'description'] + nullable_fields = ('color', 'description') class RackBulkEditForm(NetBoxModelBulkEditForm): @@ -278,9 +278,9 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - ] + ) class RackReservationBulkEditForm(NetBoxModelBulkEditForm): @@ -304,9 +304,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = [] - class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -319,7 +316,7 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = ('description',) class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -350,7 +347,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['part_number', 'airflow'] + nullable_fields = ('part_number', 'airflow') class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -367,7 +364,7 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['part_number'] + nullable_fields = ('part_number',) class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -389,7 +386,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['color', 'description'] + nullable_fields = ('color', 'description') class PlatformBulkEditForm(NetBoxModelBulkEditForm): @@ -412,7 +409,7 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['manufacturer', 'napalm_driver', 'description'] + nullable_fields = ('manufacturer', 'napalm_driver', 'description') class DeviceBulkEditForm(NetBoxModelBulkEditForm): @@ -471,9 +468,9 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'tenant', 'platform', 'serial', 'airflow', - ] + ) class ModuleBulkEditForm(NetBoxModelBulkEditForm): @@ -499,7 +496,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['serial'] + nullable_fields = ('serial',) class CableBulkEditForm(NetBoxModelBulkEditForm): @@ -542,9 +539,9 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'type', 'status', 'tenant', 'label', 'color', 'length', - ] + ) def clean(self): super().clean() @@ -569,7 +566,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['domain'] + nullable_fields = ('domain',) class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): @@ -608,7 +605,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['location'] + nullable_fields = ('location',) class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): @@ -667,9 +664,9 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'location', 'comments', - ] + ) # @@ -930,7 +927,7 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description'] + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') # @@ -951,7 +948,7 @@ class ConsolePortBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class ConsoleServerPortBulkEditForm( @@ -968,7 +965,7 @@ class ConsoleServerPortBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class PowerPortBulkEditForm( @@ -985,7 +982,7 @@ class PowerPortBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class PowerOutletBulkEditForm( @@ -1008,7 +1005,7 @@ class PowerOutletBulkEditForm( ) class Meta: - nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] + nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1088,10 +1085,11 @@ class InterfaceBulkEditForm( ) class Meta: - nullable_fields = [ - 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', - ] + nullable_fields = ( + 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', + 'vrf', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1157,7 +1155,7 @@ class FrontPortBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class RearPortBulkEditForm( @@ -1170,7 +1168,7 @@ class RearPortBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class ModuleBayBulkEditForm( @@ -1183,7 +1181,7 @@ class ModuleBayBulkEditForm( ) class Meta: - nullable_fields = ['label', 'position', 'description'] + nullable_fields = ('label', 'position', 'description') class DeviceBayBulkEditForm( @@ -1196,7 +1194,7 @@ class DeviceBayBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class InventoryItemBulkEditForm( @@ -1217,7 +1215,7 @@ class InventoryItemBulkEditForm( ) class Meta: - nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description'] + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') # @@ -1238,4 +1236,4 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['color', 'description'] + nullable_fields = ('color', 'description') diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 56b51c894..362592ace 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -34,7 +34,7 @@ class CustomFieldBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = [] + nullable_fields = ('description',) class CustomLinkBulkEditForm(BulkEditForm): @@ -64,9 +64,6 @@ class CustomLinkBulkEditForm(BulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = [] - class ExportTemplateBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -96,7 +93,7 @@ class ExportTemplateBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = ['description', 'mime_type', 'file_extension'] + nullable_fields = ('description', 'mime_type', 'file_extension') class WebhookBulkEditForm(BulkEditForm): @@ -139,7 +136,7 @@ class WebhookBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = ['secret', 'conditions', 'ca_file_path'] + nullable_fields = ('secret', 'conditions', 'ca_file_path') class TagBulkEditForm(BulkEditForm): @@ -156,7 +153,7 @@ class TagBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = ('description',) class ConfigContextBulkEditForm(BulkEditForm): @@ -178,9 +175,7 @@ class ConfigContextBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = [ - 'description', - ] + nullable_fields = ('description',) class JournalEntryBulkEditForm(BulkEditForm): @@ -196,6 +191,3 @@ class JournalEntryBulkEditForm(BulkEditForm): required=False, widget=forms.Textarea() ) - - class Meta: - nullable_fields = [] diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 3da03dd41..637051318 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -50,9 +50,9 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'tenant', 'description', - ] + ) class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): @@ -70,9 +70,9 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'tenant', 'description', - ] + ) class RIRBulkEditForm(NetBoxModelBulkEditForm): @@ -90,7 +90,7 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['is_private', 'description'] + nullable_fields = ('is_private', 'description') class ASNBulkEditForm(NetBoxModelBulkEditForm): @@ -117,9 +117,9 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'date_added', 'description', - ] + ) widgets = { 'date_added': DatePicker(), } @@ -148,9 +148,9 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'date_added', 'description', - ] + ) widgets = { 'date_added': DatePicker(), } @@ -170,7 +170,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = ('description',) class PrefixBulkEditForm(NetBoxModelBulkEditForm): @@ -233,9 +233,9 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'site', 'vrf', 'tenant', 'role', 'description', - ] + ) class IPRangeBulkEditForm(NetBoxModelBulkEditForm): @@ -267,9 +267,9 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'vrf', 'tenant', 'role', 'description', - ] + ) class IPAddressBulkEditForm(NetBoxModelBulkEditForm): @@ -312,9 +312,9 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'vrf', 'role', 'tenant', 'dns_name', 'description', - ] + ) class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -349,7 +349,7 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['auth_type', 'auth_key', 'description'] + nullable_fields = ('auth_type', 'auth_key', 'description') class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -379,7 +379,7 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['site', 'description'] + nullable_fields = ('site', 'description') class VLANBulkEditForm(NetBoxModelBulkEditForm): @@ -429,9 +429,9 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'site', 'group', 'tenant', 'role', 'description', - ] + ) class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): @@ -457,9 +457,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ - 'description', - ] + nullable_fields = ('description',) class ServiceBulkEditForm(ServiceTemplateBulkEditForm): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 601b0062a..63582a2d9 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -5,7 +5,7 @@ from django.db.models import Q from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices from extras.forms.customfields import CustomFieldsMixin from extras.models import CustomField, Tag -from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm +from utilities.forms import BootstrapMixin, BulkEditMixin, CSVModelForm from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( @@ -55,7 +55,7 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): return customfield.to_form_field(for_csv_import=True) -class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm): +class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditMixin, forms.Form): """ Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom fields and adding/removing tags. @@ -76,16 +76,21 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseFor """ Append form fields for all CustomFields assigned to this object type. """ + nullable_custom_fields = [] for customfield in self._get_custom_fields(self._get_content_type()): - # Annotate non-required custom fields as nullable + # Record non-required custom fields as nullable if not customfield.required: - self.nullable_fields.append(customfield.name) + nullable_custom_fields.append(customfield.name) self.fields[customfield.name] = self._get_form_field(customfield) # Annotate the field in the list of CustomField form fields self.custom_fields[customfield.name] = customfield + # Annotate nullable custom fields (if any) on the form instance + if nullable_custom_fields: + self.custom_fields = (*self.custom_fields, *nullable_custom_fields) + class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 9b14a167b..f3cc2c33d 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -32,7 +32,7 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['parent', 'description'] + nullable_fields = ('parent', 'description') class TenantBulkEditForm(NetBoxModelBulkEditForm): @@ -46,9 +46,7 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ - 'group', - ] + nullable_fields = ('group',) # @@ -70,7 +68,7 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['parent', 'description'] + nullable_fields = ('parent', 'description') class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -84,7 +82,7 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = ('description',) class ContactBulkEditForm(NetBoxModelBulkEditForm): @@ -113,4 +111,4 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments'] + nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'comments') diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 88f837b2b..d9a6c2b29 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -10,7 +10,7 @@ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSel __all__ = ( 'BootstrapMixin', 'BulkEditForm', - 'BulkEditBaseForm', + 'BulkEditMixin', 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', @@ -21,6 +21,10 @@ __all__ = ( ) +# +# Mixins +# + class BootstrapMixin: """ Add the base Bootstrap CSS classes to form elements. @@ -61,6 +65,24 @@ class BootstrapMixin: field.widget.attrs['class'] = ' '.join((css, 'form-select')).strip() +class BulkEditMixin: + """ + Base form for editing multiple objects in bulk + """ + def __init__(self, model, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = model + self.nullable_fields = () + + # Copy any nullable fields defined in Meta + if hasattr(self, 'Meta') and hasattr(self.Meta, 'nullable_fields'): + self.nullable_fields = self.Meta.nullable_fields + + +# +# Form classes +# + class ReturnURLForm(forms.Form): """ Provides a hidden return URL field to control where the user is directed after the form is submitted. @@ -75,21 +97,7 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) -class BulkEditBaseForm(forms.Form): - """ - Base form for editing multiple objects in bulk - """ - def __init__(self, model, *args, **kwargs): - super().__init__(*args, **kwargs) - self.model = model - self.nullable_fields = [] - - # Copy any nullable fields defined in Meta - if hasattr(self.Meta, 'nullable_fields'): - self.nullable_fields = self.Meta.nullable_fields - - -class BulkEditForm(BootstrapMixin, BulkEditBaseForm): +class BulkEditForm(BootstrapMixin, BulkEditMixin, forms.Form): pass diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 4232a87cd..dd846029a 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -34,7 +34,7 @@ class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = ('description',) class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -48,7 +48,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = ('description',) class ClusterBulkEditForm(NetBoxModelBulkEditForm): @@ -90,9 +90,9 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'group', 'site', 'comments', 'tenant', - ] + ) class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): @@ -145,9 +145,9 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', - ] + ) class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): @@ -198,9 +198,9 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = [ + nullable_fields = ( 'parent', 'bridge', 'mtu', 'description', - ] + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 147108d77..2d2a3ff14 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -30,7 +30,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['parent', 'description'] + nullable_fields = ('parent', 'description') class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): @@ -69,7 +69,7 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] + nullable_fields = ('ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk') class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): @@ -103,4 +103,4 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): ) class Meta: - nullable_fields = ['ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] + nullable_fields = ('ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk') From cf3ca5a661cc015baf4ef462be07e91c09db0ede Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Jan 2022 14:10:13 -0500 Subject: [PATCH 131/271] Refactor & document supported form fields --- docs/plugins/development/forms.md | 68 +++ netbox/utilities/forms/fields.py | 526 ------------------ netbox/utilities/forms/fields/__init__.py | 5 + .../utilities/forms/fields/content_types.py | 37 ++ netbox/utilities/forms/fields/csv.py | 193 +++++++ netbox/utilities/forms/fields/dynamic.py | 141 +++++ netbox/utilities/forms/fields/expandable.py | 54 ++ netbox/utilities/forms/fields/fields.py | 127 +++++ 8 files changed, 625 insertions(+), 526 deletions(-) delete mode 100644 netbox/utilities/forms/fields.py create mode 100644 netbox/utilities/forms/fields/__init__.py create mode 100644 netbox/utilities/forms/fields/content_types.py create mode 100644 netbox/utilities/forms/fields/csv.py create mode 100644 netbox/utilities/forms/fields/dynamic.py create mode 100644 netbox/utilities/forms/fields/expandable.py create mode 100644 netbox/utilities/forms/fields/fields.py diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md index 5af178194..c9c6cbde6 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -1,5 +1,7 @@ # Forms +## Form Classes + NetBox provides several base form classes for use by plugins. These are documented below. * `NetBoxModelForm` @@ -8,3 +10,69 @@ NetBox provides several base form classes for use by plugins. These are document * `NetBoxModelFilterSetForm` ### TODO: Include forms reference + +In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below. + +## General Purpose Fields + +::: utilities.forms.ColorField + selection: + members: false + +::: utilities.forms.CommentField + selection: + members: false + +::: utilities.forms.JSONField + selection: + members: false + +::: utilities.forms.MACAddressField + selection: + members: false + +::: utilities.forms.SlugField + selection: + members: false + +## Dynamic Object Fields + +::: utilities.forms.DynamicModelChoiceField + selection: + members: false + +::: utilities.forms.DynamicModelMultipleChoiceField + selection: + members: false + +## Content Type Fields + +::: utilities.forms.ContentTypeChoiceField + selection: + members: false + +::: utilities.forms.ContentTypeMultipleChoiceField + selection: + members: false + +## CSV Import Fields + +::: utilities.forms.CSVChoiceField + selection: + members: false + +::: utilities.forms.CSVMultipleChoiceField + selection: + members: false + +::: utilities.forms.CSVModelChoiceField + selection: + members: false + +::: utilities.forms.CSVContentTypeField + selection: + members: false + +::: utilities.forms.CSVMultipleContentTypeField + selection: + members: false diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py deleted file mode 100644 index ceca895c0..000000000 --- a/netbox/utilities/forms/fields.py +++ /dev/null @@ -1,526 +0,0 @@ -import csv -import json -import re -from io import StringIO -from netaddr import AddrFormatError, EUI - -import django_filters -from django import forms -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models import Count, Q -from django.forms import BoundField -from django.forms.fields import JSONField as _JSONField, InvalidJSONInput -from django.urls import reverse - -from utilities.choices import unpack_grouped_choices -from utilities.utils import content_type_identifier, content_type_name -from utilities.validators import EnhancedURLValidator -from . import widgets -from .constants import * -from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_csv, validate_csv - -__all__ = ( - 'ColorField', - 'CommentField', - 'ContentTypeChoiceField', - 'ContentTypeMultipleChoiceField', - 'CSVChoiceField', - 'CSVContentTypeField', - 'CSVDataField', - 'CSVFileField', - 'CSVModelChoiceField', - 'CSVMultipleChoiceField', - 'CSVMultipleContentTypeField', - 'CSVTypedChoiceField', - 'DynamicModelChoiceField', - 'DynamicModelMultipleChoiceField', - 'ExpandableIPAddressField', - 'ExpandableNameField', - 'JSONField', - 'LaxURLField', - 'MACAddressField', - 'SlugField', - 'TagFilterField', -) - - -class CommentField(forms.CharField): - """ - A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. - """ - widget = forms.Textarea - default_label = '' - # TODO: Port Markdown cheat sheet to internal documentation - default_helptext = ' '\ - ''\ - 'Markdown syntax is supported' - - def __init__(self, *args, **kwargs): - required = kwargs.pop('required', False) - label = kwargs.pop('label', self.default_label) - help_text = kwargs.pop('help_text', self.default_helptext) - super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) - - -class SlugField(forms.SlugField): - """ - Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. - """ - - def __init__(self, slug_source='name', *args, **kwargs): - label = kwargs.pop('label', "Slug") - help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - widget = kwargs.pop('widget', widgets.SlugWidget) - super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) - self.widget.attrs['slug-source'] = slug_source - - -class ColorField(forms.CharField): - """ - A field which represents a color in hexadecimal RRGGBB format. - """ - widget = widgets.ColorSelect - - -class TagFilterField(forms.MultipleChoiceField): - """ - A filter field for the tags of a model. Only the tags used by a model are displayed. - - :param model: The model of the filter - """ - widget = widgets.StaticSelectMultiple - - def __init__(self, model, *args, **kwargs): - def get_choices(): - tags = model.tags.annotate( - count=Count('extras_taggeditem_items') - ).order_by('name') - return [ - (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags - ] - - # Choices are fetched each time the form is initialized - super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) - - -class LaxURLField(forms.URLField): - """ - Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names - (e.g. http://myserver/ is valid) - """ - default_validators = [EnhancedURLValidator()] - - -class JSONField(_JSONField): - """ - Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Enter context data in JSON format.' - self.widget.attrs['placeholder'] = '' - - def prepare_value(self, value): - if isinstance(value, InvalidJSONInput): - return value - if value is None: - return '' - return json.dumps(value, sort_keys=True, indent=4) - - -class MACAddressField(forms.Field): - widget = forms.CharField - default_error_messages = { - 'invalid': 'MAC address must be in EUI-48 format', - } - - def to_python(self, value): - value = super().to_python(value) - - # Validate MAC address format - try: - value = EUI(value.strip()) - except AddrFormatError: - raise forms.ValidationError(self.error_messages['invalid'], code='invalid') - - return value - - -# -# Content type fields -# - -class ContentTypeChoiceMixin: - - def __init__(self, queryset, *args, **kwargs): - # Order ContentTypes by app_label - queryset = queryset.order_by('app_label', 'model') - super().__init__(queryset, *args, **kwargs) - - def label_from_instance(self, obj): - try: - return content_type_name(obj) - except AttributeError: - return super().label_from_instance(obj) - - -class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): - widget = widgets.StaticSelect - - -class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): - widget = widgets.StaticSelectMultiple - - -# -# CSV fields -# - -class CSVDataField(forms.CharField): - """ - A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first - item is a dictionary of column headers, mapping field names to the attribute by which they match a related object - (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. - - :param from_form: The form from which the field derives its validation rules. - """ - widget = forms.Textarea - - def __init__(self, from_form, *args, **kwargs): - - form = from_form() - self.model = form.Meta.model - self.fields = form.fields - self.required_fields = [ - name for name, field in form.fields.items() if field.required - ] - - super().__init__(*args, **kwargs) - - self.strip = False - if not self.label: - self.label = '' - if not self.initial: - self.initial = ','.join(self.required_fields) + '\n' - if not self.help_text: - self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ - 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ - 'in double quotes.' - - def to_python(self, value): - reader = csv.reader(StringIO(value.strip())) - - return parse_csv(reader) - - def validate(self, value): - headers, records = value - validate_csv(headers, self.fields, self.required_fields) - - return value - - -class CSVFileField(forms.FileField): - """ - A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns - data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute - by which they match a related object (where applicable). The second item is a list of dictionaries, each - representing a discrete row of CSV data. - - :param from_form: The form from which the field derives its validation rules. - """ - - def __init__(self, from_form, *args, **kwargs): - - form = from_form() - self.model = form.Meta.model - self.fields = form.fields - self.required_fields = [ - name for name, field in form.fields.items() if field.required - ] - - super().__init__(*args, **kwargs) - - def to_python(self, file): - if file is None: - return None - - csv_str = file.read().decode('utf-8').strip() - reader = csv.reader(StringIO(csv_str)) - headers, records = parse_csv(reader) - - return headers, records - - def validate(self, value): - if value is None: - return None - - headers, records = value - validate_csv(headers, self.fields, self.required_fields) - - return value - - -class CSVChoicesMixin: - STATIC_CHOICES = True - - def __init__(self, *, choices=(), **kwargs): - super().__init__(choices=choices, **kwargs) - self.choices = unpack_grouped_choices(choices) - - -class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField): - """ - A CSV field which accepts a single selection value. - """ - pass - - -class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField): - """ - A CSV field which accepts multiple selection values. - """ - def to_python(self, value): - if not value: - return [] - if not isinstance(value, str): - raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}") - return value.split(',') - - -class CSVTypedChoiceField(forms.TypedChoiceField): - STATIC_CHOICES = True - - -class CSVModelChoiceField(forms.ModelChoiceField): - """ - Provides additional validation for model choices entered as CSV data. - """ - default_error_messages = { - 'invalid_choice': 'Object not found.', - } - - def to_python(self, value): - try: - return super().to_python(value) - except MultipleObjectsReturned: - raise forms.ValidationError( - f'"{value}" is not a unique value for this field; multiple objects were found' - ) - - -class CSVContentTypeField(CSVModelChoiceField): - """ - Reference a ContentType in the form . - """ - STATIC_CHOICES = True - - def prepare_value(self, value): - return content_type_identifier(value) - - def to_python(self, value): - if not value: - return None - try: - app_label, model = value.split('.') - except ValueError: - raise forms.ValidationError(f'Object type must be specified as "."') - try: - return self.queryset.get(app_label=app_label, model=model) - except ObjectDoesNotExist: - raise forms.ValidationError(f'Invalid object type') - - -class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField): - STATIC_CHOICES = True - - # TODO: Improve validation of selected ContentTypes - def prepare_value(self, value): - if type(value) is str: - ct_filter = Q() - for name in value.split(','): - app_label, model = name.split('.') - ct_filter |= Q(app_label=app_label, model=model) - return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True)) - return content_type_identifier(value) - - -# -# Expansion fields -# - -class ExpandableNameField(forms.CharField): - """ - A field which allows for numeric range expansion - Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = """ - Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Example: [ge,xe]-0/0/[0-9] - """ - - def to_python(self, value): - if not value: - return '' - if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): - return list(expand_alphanumeric_pattern(value)) - return [value] - - -class ExpandableIPAddressField(forms.CharField): - """ - A field which allows for expansion of IP address ranges - Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Specify a numeric range to create multiple IPs.
    '\ - 'Example: 192.0.2.[1,5,100-254]/24' - - def to_python(self, value): - # Hackish address family detection but it's all we have to work with - if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 4)) - elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 6)) - return [value] - - -# -# Dynamic fields -# - -class DynamicModelChoiceMixin: - """ - :param query_params: A dictionary of additional key/value pairs to attach to the API request - :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value - :param null_option: The string used to represent a null selection (if any) - :param disabled_indicator: The name of the field which, if populated, will disable selection of the - choice (optional) - :param str fetch_trigger: The event type which will cause the select element to - fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional) - """ - filter = django_filters.ModelChoiceFilter - widget = widgets.APISelect - - def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, - fetch_trigger=None, empty_label=None, *args, **kwargs): - self.query_params = query_params or {} - self.initial_params = initial_params or {} - self.null_option = null_option - self.disabled_indicator = disabled_indicator - self.fetch_trigger = fetch_trigger - - # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference - # by widget_attrs() - self.to_field_name = kwargs.get('to_field_name') - self.empty_option = empty_label or "" - - super().__init__(*args, **kwargs) - - def widget_attrs(self, widget): - attrs = { - 'data-empty-option': self.empty_option - } - - # Set value-field attribute if the field specifies to_field_name - if self.to_field_name: - attrs['value-field'] = self.to_field_name - - # Set the string used to represent a null option - if self.null_option is not None: - attrs['data-null-option'] = self.null_option - - # Set the disabled indicator, if any - if self.disabled_indicator is not None: - attrs['disabled-indicator'] = self.disabled_indicator - - # Set the fetch trigger, if any. - if self.fetch_trigger is not None: - attrs['data-fetch-trigger'] = self.fetch_trigger - - # Attach any static query parameters - if (len(self.query_params) > 0): - widget.add_query_params(self.query_params) - - return attrs - - def get_bound_field(self, form, field_name): - bound_field = BoundField(form, self, field_name) - - # Set initial value based on prescribed child fields (if not already set) - if not self.initial and self.initial_params: - filter_kwargs = {} - for kwarg, child_field in self.initial_params.items(): - value = form.initial.get(child_field.lstrip('$')) - if value: - filter_kwargs[kwarg] = value - if filter_kwargs: - self.initial = self.queryset.filter(**filter_kwargs).first() - - # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options - # will be populated on-demand via the APISelect widget. - data = bound_field.value() - if data: - field_name = getattr(self, 'to_field_name') or 'pk' - filter = self.filter(field_name=field_name) - try: - self.queryset = filter.filter(self.queryset, data) - except (TypeError, ValueError): - # Catch any error caused by invalid initial data passed from the user - self.queryset = self.queryset.none() - else: - self.queryset = self.queryset.none() - - # Set the data URL on the APISelect widget (if not already set) - widget = bound_field.field.widget - if not widget.attrs.get('data-url'): - app_label = self.queryset.model._meta.app_label - model_name = self.queryset.model._meta.model_name - data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) - widget.attrs['data-url'] = data_url - - return bound_field - - -class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): - """ - Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be - rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. - """ - - def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ - if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE: - return None - return super().clean(value) - - -class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): - """ - A multiple-choice version of DynamicModelChoiceField. - """ - filter = django_filters.ModelMultipleChoiceFilter - widget = widgets.APISelectMultiple - - def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ - if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: - value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] - return [None, *value] - return super().clean(value) diff --git a/netbox/utilities/forms/fields/__init__.py b/netbox/utilities/forms/fields/__init__.py new file mode 100644 index 000000000..eacde0040 --- /dev/null +++ b/netbox/utilities/forms/fields/__init__.py @@ -0,0 +1,5 @@ +from .content_types import * +from .csv import * +from .dynamic import * +from .expandable import * +from .fields import * diff --git a/netbox/utilities/forms/fields/content_types.py b/netbox/utilities/forms/fields/content_types.py new file mode 100644 index 000000000..80861166c --- /dev/null +++ b/netbox/utilities/forms/fields/content_types.py @@ -0,0 +1,37 @@ +from django import forms + +from utilities.forms import widgets +from utilities.utils import content_type_name + +__all__ = ( + 'ContentTypeChoiceField', + 'ContentTypeMultipleChoiceField', +) + + +class ContentTypeChoiceMixin: + + def __init__(self, queryset, *args, **kwargs): + # Order ContentTypes by app_label + queryset = queryset.order_by('app_label', 'model') + super().__init__(queryset, *args, **kwargs) + + def label_from_instance(self, obj): + try: + return content_type_name(obj) + except AttributeError: + return super().label_from_instance(obj) + + +class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): + """ + Selection field for a single content type. + """ + widget = widgets.StaticSelect + + +class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): + """ + Selection field for one or more content types. + """ + widget = widgets.StaticSelectMultiple diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py new file mode 100644 index 000000000..275c8084c --- /dev/null +++ b/netbox/utilities/forms/fields/csv.py @@ -0,0 +1,193 @@ +import csv +from io import StringIO + +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import Q + +from utilities.choices import unpack_grouped_choices +from utilities.forms.utils import parse_csv, validate_csv +from utilities.utils import content_type_identifier + +__all__ = ( + 'CSVChoiceField', + 'CSVContentTypeField', + 'CSVDataField', + 'CSVFileField', + 'CSVModelChoiceField', + 'CSVMultipleChoiceField', + 'CSVMultipleContentTypeField', + 'CSVTypedChoiceField', +) + + +class CSVDataField(forms.CharField): + """ + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first + item is a dictionary of column headers, mapping field names to the attribute by which they match a related object + (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + widget = forms.Textarea + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + self.strip = False + if not self.label: + self.label = '' + if not self.initial: + self.initial = ','.join(self.required_fields) + '\n' + if not self.help_text: + self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ + 'in double quotes.' + + def to_python(self, value): + reader = csv.reader(StringIO(value.strip())) + + return parse_csv(reader) + + def validate(self, value): + headers, records = value + validate_csv(headers, self.fields, self.required_fields) + + return value + + +class CSVFileField(forms.FileField): + """ + A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns + data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute + by which they match a related object (where applicable). The second item is a list of dictionaries, each + representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + def to_python(self, file): + if file is None: + return None + + csv_str = file.read().decode('utf-8').strip() + reader = csv.reader(StringIO(csv_str)) + headers, records = parse_csv(reader) + + return headers, records + + def validate(self, value): + if value is None: + return None + + headers, records = value + validate_csv(headers, self.fields, self.required_fields) + + return value + + +class CSVChoicesMixin: + STATIC_CHOICES = True + + def __init__(self, *, choices=(), **kwargs): + super().__init__(choices=choices, **kwargs) + self.choices = unpack_grouped_choices(choices) + + +class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField): + """ + A CSV field which accepts a single selection value. + """ + pass + + +class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField): + """ + A CSV field which accepts multiple selection values. + """ + def to_python(self, value): + if not value: + return [] + if not isinstance(value, str): + raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}") + return value.split(',') + + +class CSVTypedChoiceField(forms.TypedChoiceField): + STATIC_CHOICES = True + + +class CSVModelChoiceField(forms.ModelChoiceField): + """ + Extends Django's `ModelChoiceField` to provide additional validation for CSV values. + """ + default_error_messages = { + 'invalid_choice': 'Object not found.', + } + + def to_python(self, value): + try: + return super().to_python(value) + except MultipleObjectsReturned: + raise forms.ValidationError( + f'"{value}" is not a unique value for this field; multiple objects were found' + ) + + +class CSVContentTypeField(CSVModelChoiceField): + """ + CSV field for referencing a single content type, in the form `.`. + """ + STATIC_CHOICES = True + + def prepare_value(self, value): + return content_type_identifier(value) + + def to_python(self, value): + if not value: + return None + try: + app_label, model = value.split('.') + except ValueError: + raise forms.ValidationError(f'Object type must be specified as "."') + try: + return self.queryset.get(app_label=app_label, model=model) + except ObjectDoesNotExist: + raise forms.ValidationError(f'Invalid object type') + + +class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField): + """ + CSV field for referencing one or more content types, in the form `.`. + """ + STATIC_CHOICES = True + + # TODO: Improve validation of selected ContentTypes + def prepare_value(self, value): + if type(value) is str: + ct_filter = Q() + for name in value.split(','): + app_label, model = name.split('.') + ct_filter |= Q(app_label=app_label, model=model) + return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True)) + return content_type_identifier(value) diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py new file mode 100644 index 000000000..1bc8b9ec4 --- /dev/null +++ b/netbox/utilities/forms/fields/dynamic.py @@ -0,0 +1,141 @@ +import django_filters +from django import forms +from django.conf import settings +from django.forms import BoundField +from django.urls import reverse + +from utilities.forms import widgets + +__all__ = ( + 'DynamicModelChoiceField', + 'DynamicModelMultipleChoiceField', +) + + +class DynamicModelChoiceMixin: + """ + Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + + Attributes: + query_params: A dictionary of additional key/value pairs to attach to the API request + initial_params: A dictionary of child field references to use for selecting a parent field's initial value + null_option: The string used to represent a null selection (if any) + disabled_indicator: The name of the field which, if populated, will disable selection of the + choice (optional) + fetch_trigger: The event type which will cause the select element to + fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional) + """ + filter = django_filters.ModelChoiceFilter + widget = widgets.APISelect + + def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, + fetch_trigger=None, empty_label=None, *args, **kwargs): + self.query_params = query_params or {} + self.initial_params = initial_params or {} + self.null_option = null_option + self.disabled_indicator = disabled_indicator + self.fetch_trigger = fetch_trigger + + # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference + # by widget_attrs() + self.to_field_name = kwargs.get('to_field_name') + self.empty_option = empty_label or "" + + super().__init__(*args, **kwargs) + + def widget_attrs(self, widget): + attrs = { + 'data-empty-option': self.empty_option + } + + # Set value-field attribute if the field specifies to_field_name + if self.to_field_name: + attrs['value-field'] = self.to_field_name + + # Set the string used to represent a null option + if self.null_option is not None: + attrs['data-null-option'] = self.null_option + + # Set the disabled indicator, if any + if self.disabled_indicator is not None: + attrs['disabled-indicator'] = self.disabled_indicator + + # Set the fetch trigger, if any. + if self.fetch_trigger is not None: + attrs['data-fetch-trigger'] = self.fetch_trigger + + # Attach any static query parameters + if (len(self.query_params) > 0): + widget.add_query_params(self.query_params) + + return attrs + + def get_bound_field(self, form, field_name): + bound_field = BoundField(form, self, field_name) + + # Set initial value based on prescribed child fields (if not already set) + if not self.initial and self.initial_params: + filter_kwargs = {} + for kwarg, child_field in self.initial_params.items(): + value = form.initial.get(child_field.lstrip('$')) + if value: + filter_kwargs[kwarg] = value + if filter_kwargs: + self.initial = self.queryset.filter(**filter_kwargs).first() + + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options + # will be populated on-demand via the APISelect widget. + data = bound_field.value() + if data: + field_name = getattr(self, 'to_field_name') or 'pk' + filter = self.filter(field_name=field_name) + try: + self.queryset = filter.filter(self.queryset, data) + except (TypeError, ValueError): + # Catch any error caused by invalid initial data passed from the user + self.queryset = self.queryset.none() + else: + self.queryset = self.queryset.none() + + # Set the data URL on the APISelect widget (if not already set) + widget = bound_field.field.widget + if not widget.attrs.get('data-url'): + app_label = self.queryset.model._meta.app_label + model_name = self.queryset.model._meta.model_name + data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) + widget.attrs['data-url'] = data_url + + return bound_field + + +class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): + """ + Dynamic selection field for a single object, backed by NetBox's REST API. + """ + def clean(self, value): + """ + When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. + """ + if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE: + return None + return super().clean(value) + + +class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): + """ + A multiple-choice version of `DynamicModelChoiceField`. + """ + filter = django_filters.ModelMultipleChoiceFilter + widget = widgets.APISelectMultiple + + def clean(self, value): + """ + When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. + """ + if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: + value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] + return [None, *value] + return super().clean(value) diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py new file mode 100644 index 000000000..214775f03 --- /dev/null +++ b/netbox/utilities/forms/fields/expandable.py @@ -0,0 +1,54 @@ +import re + +from django import forms + +from utilities.forms.constants import * +from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern + +__all__ = ( + 'ExpandableIPAddressField', + 'ExpandableNameField', +) + + +class ExpandableNameField(forms.CharField): + """ + A field which allows for numeric range expansion + Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = """ + Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range + are not supported. Example: [ge,xe]-0/0/[0-9] + """ + + def to_python(self, value): + if not value: + return '' + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): + return list(expand_alphanumeric_pattern(value)) + return [value] + + +class ExpandableIPAddressField(forms.CharField): + """ + A field which allows for expansion of IP address ranges + Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Specify a numeric range to create multiple IPs.
    '\ + 'Example: 192.0.2.[1,5,100-254]/24' + + def to_python(self, value): + # Hackish address family detection but it's all we have to work with + if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 4)) + elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 6)) + return [value] diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py new file mode 100644 index 000000000..c2357a6e8 --- /dev/null +++ b/netbox/utilities/forms/fields/fields.py @@ -0,0 +1,127 @@ +import json + +from django import forms +from django.db.models import Count +from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from netaddr import AddrFormatError, EUI + +from utilities.forms import widgets +from utilities.validators import EnhancedURLValidator + +__all__ = ( + 'ColorField', + 'CommentField', + 'JSONField', + 'LaxURLField', + 'MACAddressField', + 'SlugField', + 'TagFilterField', +) + + +class CommentField(forms.CharField): + """ + A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. + """ + widget = forms.Textarea + # TODO: Port Markdown cheat sheet to internal documentation + help_text = """ + + + Markdown syntax is supported + """ + + def __init__(self, *, help_text=help_text, required=False, **kwargs): + super().__init__(help_text=help_text, required=required, **kwargs) + + +class SlugField(forms.SlugField): + """ + Extend Django's built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + + Parameters: + slug_source: Name of the form field from which the slug value will be derived + """ + widget = widgets.SlugWidget + help_text = "URL-friendly unique shorthand" + + def __init__(self, *, slug_source='name', help_text=help_text, **kwargs): + super().__init__(help_text=help_text, **kwargs) + + self.widget.attrs['slug-source'] = slug_source + + +class ColorField(forms.CharField): + """ + A field which represents a color value in hexadecimal `RRGGBB` format. Utilizes NetBox's `ColorSelect` widget to + render choices. + """ + widget = widgets.ColorSelect + + +class TagFilterField(forms.MultipleChoiceField): + """ + A filter field for the tags of a model. Only the tags used by a model are displayed. + + :param model: The model of the filter + """ + widget = widgets.StaticSelectMultiple + + def __init__(self, model, *args, **kwargs): + def get_choices(): + tags = model.tags.annotate( + count=Count('extras_taggeditem_items') + ).order_by('name') + return [ + (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags + ] + + # Choices are fetched each time the form is initialized + super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) + + +class LaxURLField(forms.URLField): + """ + Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names + (e.g. http://myserver/ is valid) + """ + default_validators = [EnhancedURLValidator()] + + +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if isinstance(value, InvalidJSONInput): + return value + if value is None: + return '' + return json.dumps(value, sort_keys=True, indent=4) + + +class MACAddressField(forms.Field): + """ + Validates a 48-bit MAC address. + """ + widget = forms.CharField + default_error_messages = { + 'invalid': 'MAC address must be in EUI-48 format', + } + + def to_python(self, value): + value = super().to_python(value) + + # Validate MAC address format + try: + value = EUI(value.strip()) + except AddrFormatError: + raise forms.ValidationError(self.error_messages['invalid'], code='invalid') + + return value From ccb3a7528114b9c42f59e579687585218522828e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Jan 2022 15:52:36 -0500 Subject: [PATCH 132/271] Move fieldsets out of Meta for model forms --- netbox/circuits/forms/models.py | 25 ++--- netbox/dcim/forms/models.py | 114 ++++++++++++---------- netbox/extras/forms/models.py | 61 ++++++------ netbox/ipam/forms/models.py | 80 ++++++++------- netbox/netbox/forms/base.py | 6 ++ netbox/templates/generic/object_edit.html | 4 +- netbox/templates/users/preferences.html | 2 +- netbox/tenancy/forms/models.py | 14 +-- netbox/users/forms.py | 20 ++-- netbox/virtualization/forms/models.py | 26 ++--- netbox/wireless/forms/models.py | 24 ++--- 11 files changed, 206 insertions(+), 170 deletions(-) diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index bf5d92e85..2246573ba 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -27,15 +27,16 @@ class ProviderForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Provider', ('name', 'slug', 'asn', 'tags')), + ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ) + class Meta: model = Provider fields = [ 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', ] - fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'tags')), - ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), - ) widgets = { 'noc_contact': SmallTextarea( attrs={'rows': 5} @@ -63,14 +64,15 @@ class ProviderNetworkForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')), + ) + class Meta: model = ProviderNetwork fields = [ 'provider', 'name', 'service_id', 'description', 'comments', 'tags', ] - fieldsets = ( - ('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')), - ) class CircuitTypeForm(NetBoxModelForm): @@ -100,16 +102,17 @@ class CircuitForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = Circuit fields = [ 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'comments', 'tags', ] - fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) help_texts = { 'cid': "Unique circuit ID", 'commit_rate': "Committed rate", diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 80e785940..92f74036a 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -134,19 +134,20 @@ class SiteForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Site', ( + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), + ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')), + ) + class Meta: model = Site fields = ( 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', ) - fieldsets = ( - ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', - )), - ('Tenancy', ('tenant_group', 'tenant')), - ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')), - ) widgets = { 'physical_address': SmallTextarea( attrs={ @@ -208,17 +209,18 @@ class LocationForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Location', ( + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = Location fields = ( 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', ) - fieldsets = ( - ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', - )), - ('Tenancy', ('tenant_group', 'tenant')), - ) class RackRoleForm(NetBoxModelForm): @@ -347,16 +349,17 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = RackReservation fields = [ 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', ] - fieldsets = ( - ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) class ManufacturerForm(NetBoxModelForm): @@ -386,21 +389,22 @@ class DeviceTypeForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Device Type', ( + 'manufacturer', 'model', 'slug', 'part_number', 'tags', + )), + ('Chassis', ( + 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + )), + ('Images', ('front_image', 'rear_image')), + ) + class Meta: model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', ] - fieldsets = ( - ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'tags', - )), - ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - )), - ('Images', ('front_image', 'rear_image')), - ) widgets = { 'subdevice_role': StaticSelect(), 'front_image': ClearableFileInput(attrs={ @@ -745,14 +749,15 @@ class PowerPanelForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), + ) + class Meta: model = PowerPanel fields = [ 'region', 'site_group', 'site', 'location', 'name', 'tags', ] - fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), - ) class PowerFeedForm(NetBoxModelForm): @@ -800,17 +805,18 @@ class PowerFeedForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), + ) + class Meta: model = PowerFeed fields = [ 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] - fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), - ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), - ) widgets = { 'status': StaticSelect(), 'type': StaticSelect(), @@ -1101,16 +1107,17 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): widget=forms.HiddenInput ) + fieldsets = ( + ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')), + ('Hardware', ('manufacturer', 'part_id')), + ) + class Meta: model = InventoryItemTemplate fields = [ 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'component_type', 'component_id', ] - fieldsets = ( - ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')), - ('Hardware', ('manufacturer', 'part_id')), - ) widgets = { 'device_type': forms.HiddenInput(), } @@ -1271,6 +1278,17 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), + ('Addressing', ('vrf', 'mac_address', 'wwn')), + ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + ('Wireless', ( + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', + )), + ) + class Meta: model = Interface fields = [ @@ -1278,17 +1296,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] - fieldsets = ( - ('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), - ('Addressing', ('vrf', 'mac_address', 'wwn')), - ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), - ('Related Interfaces', ('parent', 'bridge', 'lag')), - ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), - ('Wireless', ( - 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', - 'wireless_lans', - )), - ) widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), @@ -1432,16 +1439,17 @@ class InventoryItemForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), + ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), + ) + class Meta: model = InventoryItem fields = [ 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'component_type', 'component_id', 'tags', ] - fieldsets = ( - ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), - ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), - ) widgets = { 'device': forms.HiddenInput(), } diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 5c29a8381..c391665b3 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -30,16 +30,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): limit_choices_to=FeatureQuery('custom_fields') ) + fieldsets = ( + ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), + ('Assigned Models', ('content_types',)), + ('Behavior', ('filter_logic',)), + ('Values', ('default', 'choices')), + ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), + ) + class Meta: model = CustomField fields = '__all__' - fieldsets = ( - ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), - ('Assigned Models', ('content_types',)), - ('Behavior', ('filter_logic',)), - ('Values', ('default', 'choices')), - ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), - ) widgets = { 'type': StaticSelect(), 'filter_logic': StaticSelect(), @@ -52,13 +53,14 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): limit_choices_to=FeatureQuery('custom_links') ) + fieldsets = ( + ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), + ('Templates', ('link_text', 'link_url')), + ) + class Meta: model = CustomLink fields = '__all__' - fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), - ('Templates', ('link_text', 'link_url')), - ) widgets = { 'button_class': StaticSelect(), 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), @@ -77,14 +79,15 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): limit_choices_to=FeatureQuery('export_templates') ) + fieldsets = ( + ('Export Template', ('name', 'content_type', 'description')), + ('Template', ('template_code',)), + ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), + ) + class Meta: model = ExportTemplate fields = '__all__' - fieldsets = ( - ('Export Template', ('name', 'content_type', 'description')), - ('Template', ('template_code',)), - ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), - ) widgets = { 'template_code': forms.Textarea(attrs={'class': 'font-monospace'}), } @@ -96,18 +99,19 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): limit_choices_to=FeatureQuery('webhooks') ) + fieldsets = ( + ('Webhook', ('name', 'content_types', 'enabled')), + ('Events', ('type_create', 'type_update', 'type_delete')), + ('HTTP Request', ( + 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', + )), + ('Conditions', ('conditions',)), + ('SSL', ('ssl_verification', 'ca_file_path')), + ) + class Meta: model = Webhook fields = '__all__' - fieldsets = ( - ('Webhook', ('name', 'content_types', 'enabled')), - ('Events', ('type_create', 'type_update', 'type_delete')), - ('HTTP Request', ( - 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - )), - ('Conditions', ('conditions',)), - ('SSL', ('ssl_verification', 'ca_file_path')), - ) labels = { 'type_create': 'Creations', 'type_update': 'Updates', @@ -123,14 +127,15 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() + fieldsets = ( + ('Tag', ('name', 'slug', 'color', 'description')), + ) + class Meta: model = Tag fields = [ 'name', 'slug', 'color', 'description' ] - fieldsets = ( - ('Tag', ('name', 'slug', 'color', 'description')), - ) class ConfigContextForm(BootstrapMixin, forms.ModelForm): diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index e86fe1dab..68016a0e5 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -53,17 +53,18 @@ class VRFForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), + ('Route Targets', ('import_targets', 'export_targets')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = VRF fields = [ 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', 'tags', ] - fieldsets = ( - ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), - ('Route Targets', ('import_targets', 'export_targets')), - ('Tenancy', ('tenant_group', 'tenant')), - ) labels = { 'rd': "RD", } @@ -78,15 +79,16 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Route Target', ('name', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = RouteTarget fields = [ 'name', 'description', 'tenant_group', 'tenant', 'tags', ] - fieldsets = ( - ('Route Target', ('name', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) class RIRForm(NetBoxModelForm): @@ -113,15 +115,16 @@ class AggregateForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = Aggregate fields = [ 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', ] - fieldsets = ( - ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) help_texts = { 'prefix': "IPv4 or IPv6 network", 'rir': "Regional Internet Registry responsible for this prefix", @@ -146,15 +149,16 @@ class ASNForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = ASN fields = [ 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' ] - fieldsets = ( - ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) help_texts = { 'asn': "AS number", 'rir': "Regional Internet Registry responsible for this prefix", @@ -248,17 +252,18 @@ class PrefixForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), + ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = Prefix fields = [ 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'tenant_group', 'tenant', 'tags', ] - fieldsets = ( - ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), - ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), - ('Tenancy', ('tenant_group', 'tenant')), - ) widgets = { 'status': StaticSelect(), } @@ -279,15 +284,16 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = IPRange fields = [ 'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', ] - fieldsets = ( - ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) widgets = { 'status': StaticSelect(), } @@ -562,16 +568,17 @@ class FHRPGroupForm(NetBoxModelForm): label='Status' ) + fieldsets = ( + ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_key')), + ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) + ) + class Meta: model = FHRPGroup fields = ( 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', ) - fieldsets = ( - ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), - ('Authentication', ('auth_type', 'auth_key')), - ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) - ) def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) @@ -699,17 +706,18 @@ class VLANGroupForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('VLAN Group', ('name', 'slug', 'description', 'tags')), + ('Child VLANs', ('min_vid', 'max_vid')), + ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), + ) + class Meta: model = VLANGroup fields = [ 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags', ] - fieldsets = ( - ('VLAN Group', ('name', 'slug', 'description', 'tags')), - ('Child VLANs', ('min_vid', 'max_vid')), - ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), - ) widgets = { 'scope_type': StaticSelect, } diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 63582a2d9..3fa85f1f7 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -19,7 +19,13 @@ __all__ = ( class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): """ Base form for creating & editing NetBox models. Adds support for custom fields. + + Attributes: + fieldsets: An iterable of two-tuples which define a heading and field set to display per section of + the rendered form (optional). If not defined, the all fields will be rendered as a single section. """ + fieldsets = () + def _get_content_type(self): return ContentType.objects.get_for_model(self._meta.model) diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 5dc8f995d..fbf7fd394 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -33,7 +33,7 @@ {% csrf_token %} {% block form %} - {% if form.Meta.fieldsets %} + {% if form.fieldsets %} {# Render hidden fields #} {% for field in form.hidden_fields %} @@ -41,7 +41,7 @@ {% endfor %} {# Render grouped fields according to Form #} - {% for group, fields in form.Meta.fieldsets %} + {% for group, fields in form.fieldsets %}
    {{ group }}
    diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 2a34f1b3f..b9b0be665 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -8,7 +8,7 @@ {% csrf_token %} - {% for group, fields in form.Meta.fieldsets %} + {% for group, fields in form.fieldsets %}
    {{ group }}
    diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 313b55417..5fe299cad 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -52,14 +52,15 @@ class TenantForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), + ) + class Meta: model = Tenant fields = ( 'name', 'slug', 'group', 'description', 'comments', 'tags', ) - fieldsets = ( - ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), - ) # @@ -105,14 +106,15 @@ class ContactForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')), + ) + class Meta: model = Contact fields = ( 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', ) - fieldsets = ( - ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')), - ) widgets = { 'address': SmallTextarea(attrs={'rows': 3}), } diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 5a99adc5a..49c080853 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -40,20 +40,20 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass): + fieldsets = ( + ('User Interface', ( + 'pagination.per_page', + 'pagination.placement', + 'ui.colormode', + )), + ('Miscellaneous', ( + 'data_format', + )), + ) class Meta: model = UserConfig fields = () - fieldsets = ( - ('User Interface', ( - 'pagination.per_page', - 'pagination.placement', - 'ui.colormode', - )), - ('Miscellaneous', ( - 'data_format', - )), - ) def __init__(self, *args, instance=None, **kwargs): diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 883fcd363..ecd909ec2 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -90,15 +90,16 @@ class ClusterForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + class Meta: model = Cluster fields = ( 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) - fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) class ClusterAddDevicesForm(BootstrapMixin, forms.Form): @@ -206,20 +207,21 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False ) + fieldsets = ( + ('Virtual Machine', ('name', 'role', 'status', 'tags')), + ('Cluster', ('cluster_group', 'cluster')), + ('Tenancy', ('tenant_group', 'tenant')), + ('Management', ('platform', 'primary_ip4', 'primary_ip6')), + ('Resources', ('vcpus', 'memory', 'disk')), + ('Config Context', ('local_context_data',)), + ) + class Meta: model = VirtualMachine fields = [ 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] - fieldsets = ( - ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), - ('Tenancy', ('tenant_group', 'tenant')), - ('Management', ('platform', 'primary_ip4', 'primary_ip6')), - ('Resources', ('vcpus', 'memory', 'disk')), - ('Config Context', ('local_context_data',)), - ) help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " "config context", diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 30a4a2352..2ed4a5766 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -45,16 +45,17 @@ class WirelessLANForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), + ('VLAN', ('vlan',)), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) + class Meta: model = WirelessLAN fields = [ 'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] - fieldsets = ( - ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), - ('VLAN', ('vlan',)), - ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), - ) widgets = { 'auth_type': StaticSelect, 'auth_cipher': StaticSelect, @@ -141,18 +142,19 @@ class WirelessLinkForm(NetBoxModelForm): required=False ) + fieldsets = ( + ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), + ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), + ('Link', ('status', 'ssid', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) + class Meta: model = WirelessLink fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] - fieldsets = ( - ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), - ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), - ('Link', ('status', 'ssid', 'description', 'tags')), - ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), - ) widgets = { 'status': StaticSelect, 'auth_type': StaticSelect, From 353e132cf95b82b9d61cbadc7c34e054c3030b88 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Jan 2022 16:03:26 -0500 Subject: [PATCH 133/271] Replace filter_groups with fieldsets on filter forms --- netbox/circuits/forms/filtersets.py | 30 +-- netbox/dcim/forms/filtersets.py | 247 +++++++++++----------- netbox/extras/forms/filtersets.py | 69 +++--- netbox/ipam/forms/filtersets.py | 114 +++++----- netbox/templates/inc/filter_list.html | 31 +-- netbox/tenancy/forms/filtersets.py | 8 - netbox/virtualization/forms/filtersets.py | 36 ++-- netbox/wireless/forms/filtersets.py | 9 +- 8 files changed, 270 insertions(+), 274 deletions(-) diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 18f914b58..e7e5287a6 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -18,11 +18,11 @@ __all__ = ( class ProviderFilterForm(NetBoxModelFilterSetForm): model = Provider - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['asn'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('ASN', ('asn',)), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -51,9 +51,9 @@ class ProviderFilterForm(NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork - field_groups = ( - ('q', 'tag'), - ('provider_id',), + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('provider_id', 'service_id')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), @@ -74,13 +74,13 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Circuit - field_groups = [ - ['q', 'tag'], - ['provider_id', 'provider_network_id'], - ['type_id', 'status', 'commit_rate'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Provider', ('provider_id', 'provider_network_id')), + ('Attributes', ('type_id', 'status', 'commit_rate')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), required=False, diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index e9aa3ec3f..180d0c4e7 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -126,12 +126,11 @@ class SiteGroupFilterForm(NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Site - field_groups = [ - ['q', 'tag'], - ['status', 'region_id', 'group_id'], - ['tenant_group_id', 'tenant_id'], - ['asn_id'] - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) status = forms.MultipleChoiceField( choices=SiteStatusChoices, required=False, @@ -157,11 +156,11 @@ class SiteFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class LocationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Location - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id', 'parent_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -200,13 +199,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Rack - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_id', 'location_id'], - ['status', 'role_id'], - ['type', 'width', 'serial', 'asset_tag'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_id', 'location_id')), + ('Function', ('status', 'role_id')), + ('Hardware', ('type', 'width', 'serial', 'asset_tag')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -273,12 +272,12 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation - field_groups = [ - ['q', 'tag'], - ['user_id'], - ['region_id', 'site_id', 'location_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('User', ('user_id',)), + ('Rack', ('region_id', 'site_id', 'location_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -316,11 +315,14 @@ class ManufacturerFilterForm(NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType - field_groups = [ - ['q', 'tag'], - ['manufacturer_id', 'part_number', 'subdevice_role', 'airflow'], - ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', + 'pass_through_ports', + )), + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -386,11 +388,14 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType - field_groups = [ - ['q', 'tag'], - ['manufacturer_id', 'part_number'], - ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'part_number')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', + 'pass_through_ports', + )), + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -462,17 +467,17 @@ class PlatformFilterForm(NetBoxModelFilterSetForm): class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Device - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], - ['status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address'], - ['manufacturer_id', 'device_type_id', 'platform_id'], - ['tenant_group_id', 'tenant_id'], - [ - 'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports', - 'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data', - ], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), + ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', + )), + ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data')) + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -616,11 +621,10 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module - field_groups = [ - ['q', 'tag'], - ['manufacturer_id', 'module_type_id'], - ['serial', 'asset_tag'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -647,11 +651,11 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -676,12 +680,12 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable - field_groups = [ - ['q', 'tag'], - ['site_id', 'rack_id', 'device_id'], - ['type', 'status', 'color', 'length', 'length_unit'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('site_id', 'rack_id', 'device_id')), + ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -739,9 +743,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(NetBoxModelFilterSetForm): model = PowerPanel - field_groups = ( - ('q', 'tag'), - ('region_id', 'site_group_id', 'site_id', 'location_id') + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')) ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -776,12 +780,11 @@ class PowerPanelFilterForm(NetBoxModelFilterSetForm): class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['power_panel_id', 'rack_id'], - ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), + ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -856,11 +859,11 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm): class ConsolePortFilterForm(DeviceComponentFilterForm): model = ConsolePort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, required=False, @@ -876,11 +879,11 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): class ConsoleServerPortFilterForm(DeviceComponentFilterForm): model = ConsoleServerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, required=False, @@ -896,11 +899,11 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): class PowerPortFilterForm(DeviceComponentFilterForm): model = PowerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) type = forms.MultipleChoiceField( choices=PowerPortTypeChoices, required=False, @@ -911,11 +914,11 @@ class PowerPortFilterForm(DeviceComponentFilterForm): class PowerOutletFilterForm(DeviceComponentFilterForm): model = PowerOutlet - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) type = forms.MultipleChoiceField( choices=PowerOutletTypeChoices, required=False, @@ -926,13 +929,13 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only'], - ['vrf_id', 'mac_address', 'wwn'], - ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), + ('Addressing', ('vrf_id', 'mac_address', 'wwn')), + ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) kind = forms.MultipleChoiceField( choices=InterfaceKindChoices, required=False, @@ -1009,11 +1012,11 @@ class InterfaceFilterForm(DeviceComponentFilterForm): class FrontPortFilterForm(DeviceComponentFilterForm): - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'color')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) model = FrontPort type = forms.MultipleChoiceField( choices=PortTypeChoices, @@ -1028,11 +1031,11 @@ class FrontPortFilterForm(DeviceComponentFilterForm): class RearPortFilterForm(DeviceComponentFilterForm): model = RearPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'color')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) type = forms.MultipleChoiceField( choices=PortTypeChoices, required=False, @@ -1046,11 +1049,11 @@ class RearPortFilterForm(DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'position'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'position')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) tag = TagFilterField(model) position = forms.CharField( required=False @@ -1059,21 +1062,21 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay - field_groups = [ - ['q', 'tag'], - ['name', 'label'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) tag = TagFilterField(model) class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), required=False, diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 330bb91e3..760f873c3 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -28,11 +28,10 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): - field_groups = [ - ['q'], - ['type', 'content_types'], - ['weight', 'required'], - ] + fieldsets = ( + (None, ('q',)), + ('Attributes', ('type', 'content_types', 'weight', 'required')), + ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -56,10 +55,10 @@ class CustomFieldFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm): - field_groups = [ - ['q'], - ['content_type', 'enabled', 'new_window', 'weight'], - ] + fieldsets = ( + (None, ('q',)), + ('Attributes', ('content_type', 'enabled', 'new_window', 'weight')), + ) content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -83,10 +82,10 @@ class CustomLinkFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm): - field_groups = [ - ['q'], - ['content_type', 'mime_type', 'file_extension', 'as_attachment'], - ] + fieldsets = ( + (None, ('q',)), + ('Attributes', ('content_type', 'mime_type', 'file_extension', 'as_attachment')), + ) content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -108,11 +107,11 @@ class ExportTemplateFilterForm(FilterForm): class WebhookFilterForm(FilterForm): - field_groups = [ - ['q'], - ['content_types', 'http_method', 'enabled'], - ['type_create', 'type_update', 'type_delete'], - ] + fieldsets = ( + (None, ('q',)), + ('Attributes', ('content_types', 'http_method', 'enabled')), + ('Events', ('type_create', 'type_update', 'type_delete')), + ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), @@ -160,13 +159,13 @@ class TagFilterForm(FilterForm): class ConfigContextFilterForm(FilterForm): - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['device_type_id', 'platform_id', 'role_id'], - ['cluster_type_id', 'cluster_group_id', 'cluster_id'], - ['tenant_group_id', 'tenant_id'] - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Device', ('device_type_id', 'platform_id', 'role_id')), + ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')) + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -243,11 +242,11 @@ class LocalConfigContextFilterForm(forms.Form): class JournalEntryFilterForm(FilterForm): model = JournalEntry - field_groups = [ - ['q'], - ['created_before', 'created_after', 'created_by_id'], - ['assigned_object_type_id', 'kind'] - ] + fieldsets = ( + (None, ('q',)), + ('Creation', ('created_before', 'created_after', 'created_by_id')), + ('Attributes', ('assigned_object_type_id', 'kind')) + ) created_after = forms.DateTimeField( required=False, label=_('After'), @@ -283,11 +282,11 @@ class JournalEntryFilterForm(FilterForm): class ObjectChangeFilterForm(FilterForm): model = ObjectChange - field_groups = [ - ['q'], - ['time_before', 'time_after', 'action'], - ['user_id', 'changed_object_type_id'], - ] + fieldsets = ( + (None, ('q',)), + ('Time', ('time_before', 'time_after')), + ('Attributes', ('action', 'user_id', 'changed_object_type_id')), + ) time_after = forms.DateTimeField( required=False, label=_('After'), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 4301a1810..bf780a7d0 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -41,11 +41,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VRF - field_groups = [ - ['q', 'tag'], - ['import_target_id', 'export_target_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Route Targets', ('import_target_id', 'export_target_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), required=False, @@ -61,11 +61,11 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RouteTarget - field_groups = [ - ['q', 'tag'], - ['importing_vrf_id', 'exporting_vrf_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('VRF', ('importing_vrf_id', 'exporting_vrf_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) importing_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, @@ -93,11 +93,11 @@ class RIRFilterForm(NetBoxModelFilterSetForm): class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Aggregate - field_groups = [ - ['q', 'tag'], - ['family', 'rir_id'], - ['tenant_group_id', 'tenant_id'] - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('family', 'rir_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), @@ -114,12 +114,11 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN - field_groups = [ - ['q'], - ['rir_id'], - ['tenant_group_id', 'tenant_id'], - ['site_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Assignment', ('rir_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), required=False, @@ -130,6 +129,7 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, label=_('Site') ) + tag = TagFilterField(model) class RoleFilterForm(NetBoxModelFilterSetForm): @@ -139,13 +139,13 @@ class RoleFilterForm(NetBoxModelFilterSetForm): class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Prefix - field_groups = [ - ['q', 'tag'], - ['within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized'], - ['vrf_id', 'present_in_vrf_id'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'] - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')), + ('VRF', ('vrf_id', 'present_in_vrf_id')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) mask_length__lte = forms.IntegerField( widget=forms.HiddenInput() ) @@ -230,11 +230,11 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange - field_groups = [ - ['q', 'tag'], - ['family', 'vrf_id', 'status', 'role_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), @@ -263,12 +263,12 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress - field_groups = [ - ['q', 'tag'], - ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'], - ['vrf_id', 'present_in_vrf_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), + ('VRF', ('vrf_id', 'present_in_vrf_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) parent = forms.CharField( required=False, widget=forms.TextInput( @@ -323,10 +323,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class FHRPGroupFilterForm(NetBoxModelFilterSetForm): model = FHRPGroup - field_groups = ( - ('q', 'tag'), - ('protocol', 'group_id'), - ('auth_type', 'auth_key'), + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('protocol', 'group_id')), + ('Authentication', ('auth_type', 'auth_key')), ) protocol = forms.MultipleChoiceField( choices=FHRPGroupProtocolChoices, @@ -352,11 +352,11 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm): class VLANGroupFilterForm(NetBoxModelFilterSetForm): - field_groups = [ - ['q', 'tag'], - ['region', 'sitegroup', 'site', 'location', 'rack'], - ['min_vid', 'max_vid'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')), + ('VLAN ID', ('min_vid', 'max_vid')), + ) model = VLANGroup region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -396,12 +396,12 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['group_id', 'status', 'role_id', 'vid'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Attributes', ('group_id', 'status', 'role_id', 'vid')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -450,9 +450,9 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate - field_groups = ( - ('q', 'tag'), - ('protocol', 'port'), + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('protocol', 'port')), ) protocol = forms.ChoiceField( choices=add_blank_choice(ServiceProtocolChoices), diff --git a/netbox/templates/inc/filter_list.html b/netbox/templates/inc/filter_list.html index e6a1e6a28..4276764d1 100644 --- a/netbox/templates/inc/filter_list.html +++ b/netbox/templates/inc/filter_list.html @@ -7,21 +7,22 @@ {% for field in filter_form.hidden_fields %} {{ field }} {% endfor %} - {% if filter_form.field_groups %} - {# List filters by group #} - {% for group in filter_form.field_groups %} -
    - {% for name in group %} - {% with field=filter_form|get_item:name %} - {% render_field field %} - {% endwith %} - {% endfor %} -
    - {% if not forloop.last %} -
    + {# List filters by group #} + {% for heading, fields in filter_form.fieldsets %} +
    + {% if heading %} +
    {{ heading }}
    {% endif %} - {% endfor %} - {% else %} + {% for name in fields %} + {% with field=filter_form|get_item:name %} + {% render_field field %} + {% endwith %} + {% endfor %} +
    + {% if not forloop.last %} +
    + {% endif %} + {% empty %} {# List all non-customfield filters as declared in the form class #} {% for field in filter_form.visible_fields %} {% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %} @@ -30,7 +31,7 @@
    {% endif %} {% endfor %} - {% endif %} + {% endfor %} {% if filter_form.custom_fields %} {# List all custom field filters #}
    diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 8fb4b50ff..73e30cc77 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -29,10 +29,6 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm): class TenantFilterForm(NetBoxModelFilterSetForm): model = Tenant - field_groups = ( - ('q', 'tag'), - ('group_id',), - ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -63,10 +59,6 @@ class ContactRoleFilterForm(NetBoxModelFilterSetForm): class ContactFilterForm(NetBoxModelFilterSetForm): model = Contact - field_groups = ( - ('q', 'tag'), - ('group_id',), - ) group_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 292cd661d..8e3dcd143 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -32,12 +32,12 @@ class ClusterGroupFilterForm(NetBoxModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cluster - field_groups = [ - ['q', 'tag'], - ['group_id', 'type_id'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('group_id', 'type_id')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), required=False, @@ -74,13 +74,13 @@ class ClusterFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualMachine - field_groups = [ - ['q', 'tag'], - ['cluster_group_id', 'cluster_type_id', 'cluster_id'], - ['region_id', 'site_group_id', 'site_id'], - ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -154,11 +154,11 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface - field_groups = [ - ['q', 'tag'], - ['cluster_id', 'virtual_machine_id'], - ['enabled', 'mac_address'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Virtual Machine', ('cluster_id', 'virtual_machine_id')), + ('Attributes', ('enabled', 'mac_address')), + ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 3c46caf21..8dcb48673 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -26,10 +26,11 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): class WirelessLANFilterForm(NetBoxModelFilterSetForm): model = WirelessLAN - field_groups = [ - ('q', 'tag'), - ('group_id',), - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('ssid', 'group_id',)), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) ssid = forms.CharField( required=False, label='SSID' From d1672f8818de1f712854dd22011c8a7b99e9a193 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Jan 2022 16:15:40 -0500 Subject: [PATCH 134/271] Move nullable_fields out of Meta for bulk edit forms --- netbox/circuits/forms/bulk_edit.py | 24 ++-- netbox/dcim/forms/bulk_edit.py | 139 ++++++++--------------- netbox/extras/forms/bulk_edit.py | 15 +-- netbox/ipam/forms/bulk_edit.py | 69 ++++------- netbox/tenancy/forms/bulk_edit.py | 15 +-- netbox/utilities/forms/forms.py | 7 +- netbox/virtualization/forms/bulk_edit.py | 27 ++--- netbox/wireless/forms/bulk_edit.py | 13 ++- 8 files changed, 110 insertions(+), 199 deletions(-) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index f17df1302..9c20a2fe7 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -47,10 +47,9 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = ( - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - ) + nullable_fields = ( + 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ) class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): @@ -75,10 +74,9 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = ( - 'service_id', 'description', 'comments', - ) + nullable_fields = ( + 'service_id', 'description', 'comments', + ) class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -91,8 +89,7 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class CircuitBulkEditForm(NetBoxModelBulkEditForm): @@ -131,7 +128,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = ( - 'tenant', 'commit_rate', 'description', 'comments', - ) + nullable_fields = ( + 'tenant', 'commit_rate', 'description', 'comments', + ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 13e9d945b..a126d22f9 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -71,8 +71,7 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description') class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -89,8 +88,7 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description') class SiteBulkEditForm(NetBoxModelBulkEditForm): @@ -131,10 +129,9 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ( - 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', - ) + nullable_fields = ( + 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + ) class LocationBulkEditForm(NetBoxModelBulkEditForm): @@ -162,8 +159,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('parent', 'tenant', 'description') + nullable_fields = ('parent', 'tenant', 'description') class RackRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -179,8 +175,7 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('color', 'description') + nullable_fields = ('color', 'description') class RackBulkEditForm(NetBoxModelBulkEditForm): @@ -277,10 +272,9 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - ) + nullable_fields = ( + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + ) class RackReservationBulkEditForm(NetBoxModelBulkEditForm): @@ -315,8 +309,7 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -346,8 +339,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ('part_number', 'airflow') + nullable_fields = ('part_number', 'airflow') class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): @@ -363,8 +355,7 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('part_number',) + nullable_fields = ('part_number',) class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -385,8 +376,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('color', 'description') + nullable_fields = ('color', 'description') class PlatformBulkEditForm(NetBoxModelBulkEditForm): @@ -408,8 +398,7 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('manufacturer', 'napalm_driver', 'description') + nullable_fields = ('manufacturer', 'napalm_driver', 'description') class DeviceBulkEditForm(NetBoxModelBulkEditForm): @@ -467,10 +456,9 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): label='Serial Number' ) - class Meta: - nullable_fields = ( - 'tenant', 'platform', 'serial', 'airflow', - ) + nullable_fields = ( + 'tenant', 'platform', 'serial', 'airflow', + ) class ModuleBulkEditForm(NetBoxModelBulkEditForm): @@ -495,8 +483,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): label='Serial Number' ) - class Meta: - nullable_fields = ('serial',) + nullable_fields = ('serial',) class CableBulkEditForm(NetBoxModelBulkEditForm): @@ -538,10 +525,9 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ( - 'type', 'status', 'tenant', 'label', 'color', 'length', - ) + nullable_fields = ( + 'type', 'status', 'tenant', 'label', 'color', 'length', + ) def clean(self): super().clean() @@ -565,8 +551,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('domain',) + nullable_fields = ('domain',) class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): @@ -604,8 +589,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): } ) - class Meta: - nullable_fields = ('location',) + nullable_fields = ('location',) class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): @@ -663,10 +647,7 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = ( - 'location', 'comments', - ) + nullable_fields = ('location', 'comments') # @@ -688,8 +669,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ('label', 'type', 'description') + nullable_fields = ('label', 'type', 'description') class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): @@ -710,8 +690,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'description') + nullable_fields = ('label', 'type', 'description') class PowerPortTemplateBulkEditForm(BulkEditForm): @@ -742,8 +721,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') + nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateBulkEditForm(BulkEditForm): @@ -779,8 +757,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') + nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -817,8 +794,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class FrontPortTemplateBulkEditForm(BulkEditForm): @@ -842,8 +818,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class RearPortTemplateBulkEditForm(BulkEditForm): @@ -867,8 +842,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class ModuleBayTemplateBulkEditForm(BulkEditForm): @@ -884,8 +858,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'position', 'description') + nullable_fields = ('label', 'position', 'description') class DeviceBayTemplateBulkEditForm(BulkEditForm): @@ -901,8 +874,7 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class InventoryItemTemplateBulkEditForm(BulkEditForm): @@ -926,8 +898,7 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') # @@ -947,8 +918,7 @@ class ConsolePortBulkEditForm( widget=BulkEditNullBooleanSelect ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class ConsoleServerPortBulkEditForm( @@ -964,8 +934,7 @@ class ConsoleServerPortBulkEditForm( widget=BulkEditNullBooleanSelect ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class PowerPortBulkEditForm( @@ -981,8 +950,7 @@ class PowerPortBulkEditForm( widget=BulkEditNullBooleanSelect ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class PowerOutletBulkEditForm( @@ -1004,8 +972,7 @@ class PowerOutletBulkEditForm( widget=BulkEditNullBooleanSelect ) - class Meta: - nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') + nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1084,12 +1051,10 @@ class InterfaceBulkEditForm( label='VRF' ) - class Meta: - nullable_fields = ( - 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', - 'vrf', - ) + nullable_fields = ( + 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1154,8 +1119,7 @@ class FrontPortBulkEditForm( widget=forms.MultipleHiddenInput() ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class RearPortBulkEditForm( @@ -1167,8 +1131,7 @@ class RearPortBulkEditForm( widget=forms.MultipleHiddenInput() ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class ModuleBayBulkEditForm( @@ -1180,8 +1143,7 @@ class ModuleBayBulkEditForm( widget=forms.MultipleHiddenInput() ) - class Meta: - nullable_fields = ('label', 'position', 'description') + nullable_fields = ('label', 'position', 'description') class DeviceBayBulkEditForm( @@ -1193,8 +1155,7 @@ class DeviceBayBulkEditForm( widget=forms.MultipleHiddenInput() ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class InventoryItemBulkEditForm( @@ -1214,8 +1175,7 @@ class InventoryItemBulkEditForm( required=False ) - class Meta: - nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') # @@ -1235,5 +1195,4 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('color', 'description') + nullable_fields = ('color', 'description') diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 362592ace..b09bfc612 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -33,8 +33,7 @@ class CustomFieldBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class CustomLinkBulkEditForm(BulkEditForm): @@ -92,8 +91,7 @@ class ExportTemplateBulkEditForm(BulkEditForm): widget=BulkEditNullBooleanSelect() ) - class Meta: - nullable_fields = ('description', 'mime_type', 'file_extension') + nullable_fields = ('description', 'mime_type', 'file_extension') class WebhookBulkEditForm(BulkEditForm): @@ -135,8 +133,7 @@ class WebhookBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('secret', 'conditions', 'ca_file_path') + nullable_fields = ('secret', 'conditions', 'ca_file_path') class TagBulkEditForm(BulkEditForm): @@ -152,8 +149,7 @@ class TagBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class ConfigContextBulkEditForm(BulkEditForm): @@ -174,8 +170,7 @@ class ConfigContextBulkEditForm(BulkEditForm): max_length=100 ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class JournalEntryBulkEditForm(BulkEditForm): diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 637051318..9a56501d2 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -49,10 +49,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'tenant', 'description', - ) + nullable_fields = ('tenant', 'description') class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): @@ -69,10 +66,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'tenant', 'description', - ) + nullable_fields = ('tenant', 'description') class RIRBulkEditForm(NetBoxModelBulkEditForm): @@ -89,8 +83,7 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('is_private', 'description') + nullable_fields = ('is_private', 'description') class ASNBulkEditForm(NetBoxModelBulkEditForm): @@ -116,13 +109,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'date_added', 'description', - ) - widgets = { - 'date_added': DatePicker(), - } + nullable_fields = ('date_added', 'description') class AggregateBulkEditForm(NetBoxModelBulkEditForm): @@ -147,13 +134,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'date_added', 'description', - ) - widgets = { - 'date_added': DatePicker(), - } + nullable_fields = ('date_added', 'description') class RoleBulkEditForm(NetBoxModelBulkEditForm): @@ -169,8 +150,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class PrefixBulkEditForm(NetBoxModelBulkEditForm): @@ -232,10 +212,9 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'site', 'vrf', 'tenant', 'role', 'description', - ) + nullable_fields = ( + 'site', 'vrf', 'tenant', 'role', 'description', + ) class IPRangeBulkEditForm(NetBoxModelBulkEditForm): @@ -266,10 +245,9 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'vrf', 'tenant', 'role', 'description', - ) + nullable_fields = ( + 'vrf', 'tenant', 'role', 'description', + ) class IPAddressBulkEditForm(NetBoxModelBulkEditForm): @@ -311,10 +289,9 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'vrf', 'role', 'tenant', 'dns_name', 'description', - ) + nullable_fields = ( + 'vrf', 'role', 'tenant', 'dns_name', 'description', + ) class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -348,8 +325,7 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('auth_type', 'auth_key', 'description') + nullable_fields = ('auth_type', 'auth_key', 'description') class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -378,8 +354,7 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('site', 'description') + nullable_fields = ('site', 'description') class VLANBulkEditForm(NetBoxModelBulkEditForm): @@ -428,10 +403,9 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'site', 'group', 'tenant', 'role', 'description', - ) + nullable_fields = ( + 'site', 'group', 'tenant', 'role', 'description', + ) class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): @@ -456,8 +430,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class ServiceBulkEditForm(ServiceTemplateBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index f3cc2c33d..a3695c10e 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -31,8 +31,7 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description') class TenantBulkEditForm(NetBoxModelBulkEditForm): @@ -45,8 +44,7 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('group',) + nullable_fields = ('group',) # @@ -67,8 +65,7 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description') class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -81,8 +78,7 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class ContactBulkEditForm(NetBoxModelBulkEditForm): @@ -110,5 +106,4 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'comments') + nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'comments') diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index d9a6c2b29..67a2bcb74 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -69,14 +69,11 @@ class BulkEditMixin: """ Base form for editing multiple objects in bulk """ + nullable_fields = () + def __init__(self, model, *args, **kwargs): super().__init__(*args, **kwargs) self.model = model - self.nullable_fields = () - - # Copy any nullable fields defined in Meta - if hasattr(self, 'Meta') and hasattr(self.Meta, 'nullable_fields'): - self.nullable_fields = self.Meta.nullable_fields # diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index dd846029a..e4f6ab25c 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -33,8 +33,7 @@ class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -47,8 +46,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class ClusterBulkEditForm(NetBoxModelBulkEditForm): @@ -89,10 +87,9 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = ( - 'group', 'site', 'comments', 'tenant', - ) + nullable_fields = ( + 'group', 'site', 'comments', 'tenant', + ) class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): @@ -144,10 +141,9 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = ( - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', - ) + nullable_fields = ( + 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ) class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): @@ -197,10 +193,9 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ( - 'parent', 'bridge', 'mtu', 'description', - ) + nullable_fields = ( + 'parent', 'bridge', 'mtu', 'description', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 2d2a3ff14..0a3f0364e 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -29,8 +29,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description') class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): @@ -68,8 +67,9 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): label='Pre-shared key' ) - class Meta: - nullable_fields = ('ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk') + nullable_fields = ( + 'ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + ) class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): @@ -102,5 +102,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): label='Pre-shared key' ) - class Meta: - nullable_fields = ('ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk') + nullable_fields = ( + 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + ) From bfb1a82754cc66b223d4d0905451680eb7a01849 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Jan 2022 16:23:23 -0500 Subject: [PATCH 135/271] Update docstrings for base form classes --- netbox/netbox/forms/base.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 3fa85f1f7..2de15c6b8 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -18,7 +18,7 @@ __all__ = ( class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): """ - Base form for creating & editing NetBox models. Adds support for custom fields. + Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. Attributes: fieldsets: An iterable of two-tuples which define a heading and field set to display per section of @@ -65,6 +65,9 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditMixin, """ Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom fields and adding/removing tags. + + Attributes: + nullable_fields: A list of field names indicating which fields support being set to null/empty """ add_tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -100,9 +103,13 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditMixin, class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ - Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. + Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the + corresponding FilterSet *must* provide a `q` filter. - The corresponding FilterSet *must* provide a `q` filter. + Attributes: + model: The model class associated with the form + fieldsets: An iterable of two-tuples which define a heading and field set to display per section of + the rendered form (optional). If not defined, the all fields will be rendered as a single section. """ q = forms.CharField( required=False, From 3621b1a0d09a52996c72e5c7c14a502214b0362a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 11:00:18 -0500 Subject: [PATCH 136/271] Set model as attribute on bulk edit forms --- netbox/circuits/forms/bulk_edit.py | 4 +++ netbox/dcim/forms/bulk_edit.py | 30 +++++++++++++++++++++++ netbox/extras/forms/customfields.py | 7 +++++- netbox/ipam/forms/bulk_edit.py | 13 ++++++++++ netbox/netbox/forms/base.py | 6 +++-- netbox/netbox/views/generic/bulk_views.py | 4 +-- netbox/tenancy/forms/bulk_edit.py | 5 ++++ netbox/utilities/forms/forms.py | 19 ++++---------- netbox/virtualization/forms/bulk_edit.py | 5 ++++ netbox/wireless/forms/bulk_edit.py | 3 +++ 10 files changed, 77 insertions(+), 19 deletions(-) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 9c20a2fe7..43419a871 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -47,6 +47,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) + model = Provider nullable_fields = ( 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', ) @@ -74,6 +75,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) + model = ProviderNetwork nullable_fields = ( 'service_id', 'description', 'comments', ) @@ -89,6 +91,7 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = CircuitType nullable_fields = ('description',) @@ -128,6 +131,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) + model = Circuit nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index a126d22f9..c7fbcf3f1 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -71,6 +71,7 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Region nullable_fields = ('parent', 'description') @@ -88,6 +89,7 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = SiteGroup nullable_fields = ('parent', 'description') @@ -129,6 +131,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): widget=StaticSelect() ) + model = Site nullable_fields = ( 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', ) @@ -159,6 +162,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Location nullable_fields = ('parent', 'tenant', 'description') @@ -175,6 +179,7 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = RackRole nullable_fields = ('color', 'description') @@ -272,6 +277,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) + model = Rack nullable_fields = ( 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ) @@ -298,6 +304,8 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = RackReservation + class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -309,6 +317,7 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Manufacturer nullable_fields = ('description',) @@ -339,6 +348,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): widget=StaticSelect() ) + model = DeviceType nullable_fields = ('part_number', 'airflow') @@ -355,6 +365,7 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = ModuleType nullable_fields = ('part_number',) @@ -376,6 +387,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = DeviceRole nullable_fields = ('color', 'description') @@ -398,6 +410,7 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Platform nullable_fields = ('manufacturer', 'napalm_driver', 'description') @@ -456,6 +469,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): label='Serial Number' ) + model = Device nullable_fields = ( 'tenant', 'platform', 'serial', 'airflow', ) @@ -483,6 +497,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): label='Serial Number' ) + model = Module nullable_fields = ('serial',) @@ -525,6 +540,7 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): widget=StaticSelect() ) + model = Cable nullable_fields = ( 'type', 'status', 'tenant', 'label', 'color', 'length', ) @@ -551,6 +567,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = VirtualChassis nullable_fields = ('domain',) @@ -589,6 +606,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): } ) + model = PowerPanel nullable_fields = ('location',) @@ -647,6 +665,7 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) + model = PowerFeed nullable_fields = ('location', 'comments') @@ -918,6 +937,7 @@ class ConsolePortBulkEditForm( widget=BulkEditNullBooleanSelect ) + model = ConsolePort nullable_fields = ('label', 'description') @@ -934,6 +954,7 @@ class ConsoleServerPortBulkEditForm( widget=BulkEditNullBooleanSelect ) + model = ConsoleServerPort nullable_fields = ('label', 'description') @@ -950,6 +971,7 @@ class PowerPortBulkEditForm( widget=BulkEditNullBooleanSelect ) + model = PowerPort nullable_fields = ('label', 'description') @@ -972,6 +994,7 @@ class PowerOutletBulkEditForm( widget=BulkEditNullBooleanSelect ) + model = PowerOutlet nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): @@ -1051,6 +1074,7 @@ class InterfaceBulkEditForm( label='VRF' ) + model = Interface nullable_fields = ( 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', @@ -1119,6 +1143,7 @@ class FrontPortBulkEditForm( widget=forms.MultipleHiddenInput() ) + model = FrontPort nullable_fields = ('label', 'description') @@ -1131,6 +1156,7 @@ class RearPortBulkEditForm( widget=forms.MultipleHiddenInput() ) + model = RearPort nullable_fields = ('label', 'description') @@ -1143,6 +1169,7 @@ class ModuleBayBulkEditForm( widget=forms.MultipleHiddenInput() ) + model = ModuleBay nullable_fields = ('label', 'position', 'description') @@ -1155,6 +1182,7 @@ class DeviceBayBulkEditForm( widget=forms.MultipleHiddenInput() ) + model = DeviceBay nullable_fields = ('label', 'description') @@ -1175,6 +1203,7 @@ class InventoryItemBulkEditForm( required=False ) + model = InventoryItem nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') @@ -1195,4 +1224,5 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = InventoryItemRole nullable_fields = ('color', 'description') diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index c3a44f47b..bb8028eec 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -10,7 +10,12 @@ __all__ = ( class CustomFieldsMixin: """ Extend a Form to include custom field support. + + Attributes: + model: The model class """ + model = None + def __init__(self, *args, **kwargs): self.custom_fields = {} @@ -22,7 +27,7 @@ class CustomFieldsMixin: """ Return the ContentType of the form's model. """ - if not hasattr(self, 'model'): + if not getattr(self, 'model', None): raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") return ContentType.objects.get_for_model(self.model) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 9a56501d2..6002c1fe4 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -49,6 +49,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = VRF nullable_fields = ('tenant', 'description') @@ -66,6 +67,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = RouteTarget nullable_fields = ('tenant', 'description') @@ -83,6 +85,7 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = RIR nullable_fields = ('is_private', 'description') @@ -109,6 +112,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = ASN nullable_fields = ('date_added', 'description') @@ -134,6 +138,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Aggregate nullable_fields = ('date_added', 'description') @@ -150,6 +155,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Role nullable_fields = ('description',) @@ -212,6 +218,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Prefix nullable_fields = ( 'site', 'vrf', 'tenant', 'role', 'description', ) @@ -245,6 +252,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = IPRange nullable_fields = ( 'vrf', 'tenant', 'role', 'description', ) @@ -289,6 +297,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = IPAddress nullable_fields = ( 'vrf', 'role', 'tenant', 'dns_name', 'description', ) @@ -325,6 +334,7 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = FHRPGroup nullable_fields = ('auth_type', 'auth_key', 'description') @@ -354,6 +364,7 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = VLANGroup nullable_fields = ('site', 'description') @@ -403,6 +414,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = VLAN nullable_fields = ( 'site', 'group', 'tenant', 'role', 'description', ) @@ -430,6 +442,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = ServiceTemplate nullable_fields = ('description',) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2de15c6b8..9cd6bbd47 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -5,7 +5,7 @@ from django.db.models import Q from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices from extras.forms.customfields import CustomFieldsMixin from extras.models import CustomField, Tag -from utilities.forms import BootstrapMixin, BulkEditMixin, CSVModelForm +from utilities.forms import BootstrapMixin, CSVModelForm from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( @@ -61,7 +61,7 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): return customfield.to_form_field(for_csv_import=True) -class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditMixin, forms.Form): +class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom fields and adding/removing tags. @@ -69,6 +69,8 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditMixin, Attributes: nullable_fields: A list of field names indicating which fields support being set to null/empty """ + nullable_fields = () + add_tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index a042a3cff..3db44ec91 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -529,7 +529,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): initial_data['virtual_machine'] = request.GET.get('virtual_machine') if '_apply' in request.POST: - form = self.form(model, request.POST, initial=initial_data) + form = self.form(request.POST, initial=initial_data) restrict_form_fields(form, request.user) if form.is_valid(): @@ -566,7 +566,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): logger.debug("Form validation failed") else: - form = self.form(model, initial=initial_data) + form = self.form(initial=initial_data) restrict_form_fields(form, request.user) # Retrieve objects being edited diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index a3695c10e..c6d0be882 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -31,6 +31,7 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = TenantGroup nullable_fields = ('parent', 'description') @@ -44,6 +45,7 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Tenant nullable_fields = ('group',) @@ -65,6 +67,7 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = ContactGroup nullable_fields = ('parent', 'description') @@ -78,6 +81,7 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = ContactRole nullable_fields = ('description',) @@ -106,4 +110,5 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = Contact nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'comments') diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 67a2bcb74..05138df70 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -10,7 +10,6 @@ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSel __all__ = ( 'BootstrapMixin', 'BulkEditForm', - 'BulkEditMixin', 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', @@ -65,17 +64,6 @@ class BootstrapMixin: field.widget.attrs['class'] = ' '.join((css, 'form-select')).strip() -class BulkEditMixin: - """ - Base form for editing multiple objects in bulk - """ - nullable_fields = () - - def __init__(self, model, *args, **kwargs): - super().__init__(*args, **kwargs) - self.model = model - - # # Form classes # @@ -94,8 +82,11 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) -class BulkEditForm(BootstrapMixin, BulkEditMixin, forms.Form): - pass +class BulkEditForm(BootstrapMixin, forms.Form): + """ + Provides bulk edit support for objects. + """ + nullable_fields = () class BulkRenameForm(BootstrapMixin, forms.Form): diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index e4f6ab25c..b3bf8adf8 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -33,6 +33,7 @@ class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = ClusterType nullable_fields = ('description',) @@ -46,6 +47,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = ClusterGroup nullable_fields = ('description',) @@ -87,6 +89,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) + model = Cluster nullable_fields = ( 'group', 'site', 'comments', 'tenant', ) @@ -141,6 +144,7 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): label='Comments' ) + model = VirtualMachine nullable_fields = ( 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) @@ -193,6 +197,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = VMInterface nullable_fields = ( 'parent', 'bridge', 'mtu', 'description', ) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 0a3f0364e..91c34e4b1 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -29,6 +29,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) + model = WirelessLANGroup nullable_fields = ('parent', 'description') @@ -67,6 +68,7 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): label='Pre-shared key' ) + model = WirelessLAN nullable_fields = ( 'ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk', ) @@ -102,6 +104,7 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): label='Pre-shared key' ) + model = WirelessLink nullable_fields = ( 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', ) From d38620bad2cf38fb3ba3c9be3bd9a46d003fefc6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 11:05:48 -0500 Subject: [PATCH 137/271] Fix bulk nullification of custom fields --- netbox/netbox/forms/base.py | 19 ++++++++++++++----- netbox/netbox/views/generic/bulk_views.py | 6 ++++-- netbox/tenancy/forms/bulk_edit.py | 8 ++++---- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 9cd6bbd47..723ae00e2 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -71,6 +71,10 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ nullable_fields = () + pk = forms.ModelMultipleChoiceField( + queryset=None, + widget=forms.MultipleHiddenInput + ) add_tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -80,6 +84,10 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): required=False ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['pk'].queryset = self.model.objects.all() + def _get_form_field(self, customfield): return customfield.to_form_field(set_initial=False, enforce_required=False) @@ -89,18 +97,19 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): """ nullable_custom_fields = [] for customfield in self._get_custom_fields(self._get_content_type()): + field_name = f'cf_{customfield.name}' + self.fields[field_name] = self._get_form_field(customfield) + # Record non-required custom fields as nullable if not customfield.required: - nullable_custom_fields.append(customfield.name) - - self.fields[customfield.name] = self._get_form_field(customfield) + nullable_custom_fields.append(field_name) # Annotate the field in the list of CustomField form fields - self.custom_fields[customfield.name] = customfield + self.custom_fields[field_name] = customfield # Annotate nullable custom fields (if any) on the form instance if nullable_custom_fields: - self.custom_fields = (*self.custom_fields, *nullable_custom_fields) + self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 3db44ec91..a3e9b48c1 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -481,10 +481,12 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): # Update custom fields for name in custom_fields: + assert name.startswith('cf_') + cf_name = name[3:] # Strip cf_ prefix if name in form.nullable_fields and name in nullified_fields: - obj.custom_field_data[name] = None + obj.custom_field_data[cf_name] = None elif name in form.changed_data: - obj.custom_field_data[name] = form.fields[name].prepare_value(form.cleaned_data[name]) + obj.custom_field_data[cf_name] = form.fields[name].prepare_value(form.cleaned_data[name]) obj.full_clean() obj.save() diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index c6d0be882..9de4969fb 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -36,10 +36,10 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): class TenantBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Tenant.objects.all(), - widget=forms.MultipleHiddenInput() - ) + # pk = forms.ModelMultipleChoiceField( + # queryset=Tenant.objects.all(), + # widget=forms.MultipleHiddenInput() + # ) group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False From 5af18c2d8a595d78441465befc3cb187d2c3d89b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 11:40:23 -0500 Subject: [PATCH 138/271] Move pk field declaration under NetBoxModelBulkEditForm --- netbox/circuits/forms/bulk_edit.py | 16 --- netbox/dcim/forms/bulk_edit.py | 120 ----------------------- netbox/ipam/forms/bulk_edit.py | 57 +---------- netbox/netbox/forms/base.py | 2 +- netbox/tenancy/forms/bulk_edit.py | 20 ---- netbox/virtualization/forms/bulk_edit.py | 20 ---- netbox/wireless/forms/bulk_edit.py | 12 --- 7 files changed, 2 insertions(+), 245 deletions(-) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 43419a871..8c1502d84 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -15,10 +15,6 @@ __all__ = ( class ProviderBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Provider.objects.all(), - widget=forms.MultipleHiddenInput - ) asn = forms.IntegerField( required=False, label='ASN' @@ -54,10 +50,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ProviderNetwork.objects.all(), - widget=forms.MultipleHiddenInput - ) provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False @@ -82,10 +74,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=CircuitType.objects.all(), - widget=forms.MultipleHiddenInput - ) description = forms.CharField( max_length=200, required=False @@ -96,10 +84,6 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): class CircuitBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Circuit.objects.all(), - widget=forms.MultipleHiddenInput - ) type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), required=False diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index c7fbcf3f1..787f0339a 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -58,10 +58,6 @@ __all__ = ( class RegionBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Region.objects.all(), - widget=forms.MultipleHiddenInput - ) parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -76,10 +72,6 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) parent = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False @@ -94,10 +86,6 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): class SiteBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Site.objects.all(), - widget=forms.MultipleHiddenInput - ) status = forms.ChoiceField( choices=add_blank_choice(SiteStatusChoices), required=False, @@ -138,10 +126,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): class LocationBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Location.objects.all(), - widget=forms.MultipleHiddenInput - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False @@ -167,10 +151,6 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): class RackRoleBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackRole.objects.all(), - widget=forms.MultipleHiddenInput - ) color = ColorField( required=False ) @@ -184,10 +164,6 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): class RackBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Rack.objects.all(), - widget=forms.MultipleHiddenInput - ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -284,10 +260,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackReservation.objects.all(), - widget=forms.MultipleHiddenInput() - ) user = forms.ModelChoiceField( queryset=User.objects.order_by( 'username' @@ -308,10 +280,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - widget=forms.MultipleHiddenInput - ) description = forms.CharField( max_length=200, required=False @@ -322,10 +290,6 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceType.objects.all(), - widget=forms.MultipleHiddenInput() - ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -353,10 +317,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ModuleType.objects.all(), - widget=forms.MultipleHiddenInput() - ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -370,10 +330,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - widget=forms.MultipleHiddenInput - ) color = ColorField( required=False ) @@ -392,10 +348,6 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): class PlatformBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Platform.objects.all(), - widget=forms.MultipleHiddenInput - ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -415,10 +367,6 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): class DeviceBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Device.objects.all(), - widget=forms.MultipleHiddenInput() - ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -476,10 +424,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): class ModuleBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Module.objects.all(), - widget=forms.MultipleHiddenInput() - ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -502,10 +446,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): class CableBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Cable.objects.all(), - widget=forms.MultipleHiddenInput - ) type = forms.ChoiceField( choices=add_blank_choice(CableTypeChoices), required=False, @@ -558,10 +498,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualChassis.objects.all(), - widget=forms.MultipleHiddenInput() - ) domain = forms.CharField( max_length=30, required=False @@ -572,10 +508,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPanel.objects.all(), - widget=forms.MultipleHiddenInput - ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -611,10 +543,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), - widget=forms.MultipleHiddenInput - ) power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), required=False @@ -928,10 +856,6 @@ class ConsolePortBulkEditForm( form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=ConsolePort.objects.all(), - widget=forms.MultipleHiddenInput() - ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -945,10 +869,6 @@ class ConsoleServerPortBulkEditForm( form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -962,10 +882,6 @@ class PowerPortBulkEditForm( form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -979,10 +895,6 @@ class PowerOutletBulkEditForm( form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput() - ) device = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, @@ -1016,10 +928,6 @@ class InterfaceBulkEditForm( ]), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) device = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, @@ -1138,11 +1046,6 @@ class FrontPortBulkEditForm( form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - model = FrontPort nullable_fields = ('label', 'description') @@ -1151,11 +1054,6 @@ class RearPortBulkEditForm( form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - model = RearPort nullable_fields = ('label', 'description') @@ -1164,11 +1062,6 @@ class ModuleBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=ModuleBay.objects.all(), - widget=forms.MultipleHiddenInput() - ) - model = ModuleBay nullable_fields = ('label', 'position', 'description') @@ -1177,11 +1070,6 @@ class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() - ) - model = DeviceBay nullable_fields = ('label', 'description') @@ -1190,10 +1078,6 @@ class InventoryItemBulkEditForm( form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=InventoryItem.objects.all(), - widget=forms.MultipleHiddenInput() - ) role = DynamicModelChoiceField( queryset=InventoryItemRole.objects.all(), required=False @@ -1212,10 +1096,6 @@ class InventoryItemBulkEditForm( # class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=InventoryItemRole.objects.all(), - widget=forms.MultipleHiddenInput - ) color = ColorField( required=False ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 6002c1fe4..3e7bc88a4 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -31,10 +31,6 @@ __all__ = ( class VRFBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VRF.objects.all(), - widget=forms.MultipleHiddenInput() - ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -54,10 +50,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - widget=forms.MultipleHiddenInput() - ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -72,10 +64,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): class RIRBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RIR.objects.all(), - widget=forms.MultipleHiddenInput - ) is_private = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -90,10 +78,6 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): class ASNBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ASN.objects.all(), - widget=forms.MultipleHiddenInput() - ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False @@ -117,10 +101,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): class AggregateBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Aggregate.objects.all(), - widget=forms.MultipleHiddenInput() - ) rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, @@ -143,10 +123,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): class RoleBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Role.objects.all(), - widget=forms.MultipleHiddenInput - ) weight = forms.IntegerField( required=False ) @@ -160,10 +136,6 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Prefix.objects.all(), - widget=forms.MultipleHiddenInput() - ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -225,10 +197,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): class IPRangeBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=IPRange.objects.all(), - widget=forms.MultipleHiddenInput() - ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -259,10 +227,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): class IPAddressBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=IPAddress.objects.all(), - widget=forms.MultipleHiddenInput() - ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -304,10 +268,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=FHRPGroup.objects.all(), - widget=forms.MultipleHiddenInput() - ) protocol = forms.ChoiceField( choices=add_blank_choice(FHRPGroupProtocolChoices), required=False, @@ -339,10 +299,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VLANGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False @@ -369,10 +325,6 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VLAN.objects.all(), - widget=forms.MultipleHiddenInput() - ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -421,10 +373,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ServiceTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) protocol = forms.ChoiceField( choices=add_blank_choice(ServiceProtocolChoices), required=False, @@ -447,7 +395,4 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): class ServiceBulkEditForm(ServiceTemplateBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Service.objects.all(), - widget=forms.MultipleHiddenInput() - ) + model = Service diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 723ae00e2..a68548256 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -72,7 +72,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): nullable_fields = () pk = forms.ModelMultipleChoiceField( - queryset=None, + queryset=None, # Set from self.model on init widget=forms.MultipleHiddenInput ) add_tags = DynamicModelMultipleChoiceField( diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 9de4969fb..bda7e6989 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -18,10 +18,6 @@ __all__ = ( # class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False @@ -36,10 +32,6 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): class TenantBulkEditForm(NetBoxModelBulkEditForm): - # pk = forms.ModelMultipleChoiceField( - # queryset=Tenant.objects.all(), - # widget=forms.MultipleHiddenInput() - # ) group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False @@ -54,10 +46,6 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm): # class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ContactGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) parent = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False @@ -72,10 +60,6 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ContactRole.objects.all(), - widget=forms.MultipleHiddenInput - ) description = forms.CharField( max_length=200, required=False @@ -86,10 +70,6 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): class ContactBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Contact.objects.all(), - widget=forms.MultipleHiddenInput() - ) group = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index b3bf8adf8..df664c46d 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -24,10 +24,6 @@ __all__ = ( class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ClusterType.objects.all(), - widget=forms.MultipleHiddenInput - ) description = forms.CharField( max_length=200, required=False @@ -38,10 +34,6 @@ class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ClusterGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) description = forms.CharField( max_length=200, required=False @@ -52,10 +44,6 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): class ClusterBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Cluster.objects.all(), - widget=forms.MultipleHiddenInput() - ) type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), required=False @@ -96,10 +84,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualMachine.objects.all(), - widget=forms.MultipleHiddenInput() - ) status = forms.ChoiceField( choices=add_blank_choice(VirtualMachineStatusChoices), required=False, @@ -151,10 +135,6 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VMInterface.objects.all(), - widget=forms.MultipleHiddenInput() - ) virtual_machine = forms.ModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 91c34e4b1..70360d6ef 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -16,10 +16,6 @@ __all__ = ( class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=WirelessLANGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) parent = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -34,10 +30,6 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=WirelessLAN.objects.all(), - widget=forms.MultipleHiddenInput - ) group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -75,10 +67,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=WirelessLink.objects.all(), - widget=forms.MultipleHiddenInput - ) ssid = forms.CharField( max_length=SSID_MAX_LENGTH, required=False, From 74c4f12b274cefbf8fe89571be440ff734c8ecca Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 14:29:52 -0500 Subject: [PATCH 139/271] Closes #8509: CSRF_TRUSTED_ORIGINS is now a discrete configuration parameter --- docs/configuration/optional-settings.md | 15 +++++++++++++++ docs/release-notes/version-3.2.md | 1 + netbox/netbox/settings.py | 3 +-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 110ca9322..081e4b776 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -66,6 +66,21 @@ CORS_ORIGIN_WHITELIST = [ --- +## CSRF_TRUSTED_ORIGINS + +Default: `[]` + +Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). + +```python +CSRF_TRUSTED_ORIGINS = ( + 'http://netbox.local', + 'https://netbox.local', +) +``` + +--- + ## DEBUG Default: False diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 1b4a7ef87..7168a13d0 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -92,6 +92,7 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available- * [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model * [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs * [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys +* [#8509](https://github.com/netbox-community/netbox/issues/8509) - `CSRF_TRUSTED_ORIGINS` is now a discrete configuration parameter (rather than being populated from `ALLOWED_HOSTS`) ### REST API Changes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2c33ec862..4ca24fbf1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -80,6 +80,7 @@ if BASE_PATH: CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) +CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) @@ -404,8 +405,6 @@ MESSAGE_TAGS = { LOGIN_URL = f'/{BASE_PATH}login/' LOGIN_REDIRECT_URL = f'/{BASE_PATH}' -CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted From ef75f7e65030121e22a63e30e1c76f4d1f31c9ba Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 13:01:28 -0500 Subject: [PATCH 140/271] Upgrade to Django 4.0 --- requirements.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index cb6d4b6c9..26a0151e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==3.2.12 +Django==4.0.2 django-cors-headers==3.11.0 django-debug-toolbar==3.2.4 django-filter==21.1 @@ -9,11 +9,13 @@ django-prometheus==2.2.0 django-redis==5.2.0 django-rq==2.5.1 django-tables2==2.4.1 -django-taggit==2.0.0 +django-taggit==2.1.0 django-timezone-field==4.2.3 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 -graphene_django==2.15.0 +#graphene_django==2.15.0 +# Installing from PR origin temporarily +git+git://github.com/MisterGlass/graphene-django.git gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 From 7611cfddae9f127ccad934923198bf09fe7ddb95 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 13:20:03 -0500 Subject: [PATCH 141/271] Tweak related names in old migrations to avoid creating new ones --- netbox/dcim/migrations/0002_squashed.py | 18 ++++---- netbox/dcim/migrations/0003_squashed_0130.py | 16 +++---- netbox/dcim/migrations/0146_modules.py | 46 +++++++++---------- .../0149_inventoryitem_templates.py | 2 +- netbox/extras/migrations/0001_squashed.py | 4 +- .../extras/migrations/0002_squashed_0059.py | 22 ++++----- .../0068_configcontext_cluster_types.py | 2 +- 7 files changed, 55 insertions(+), 55 deletions(-) diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py index a1b6db30a..c7325210e 100644 --- a/netbox/dcim/migrations/0002_squashed.py +++ b/netbox/dcim/migrations/0002_squashed.py @@ -58,7 +58,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rearporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='rearport', @@ -73,7 +73,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rearport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='rearport', @@ -128,7 +128,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='powerporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='powerport', @@ -148,7 +148,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='powerport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='powerport', @@ -173,7 +173,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='poweroutlettemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='poweroutlettemplate', @@ -198,7 +198,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='poweroutlet', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='poweroutlet', @@ -258,7 +258,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='inventoryitem', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitems', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='inventoryitem', @@ -278,7 +278,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interfacetemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='interface', @@ -298,7 +298,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interface', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='interface', diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py index 48ea238d9..592aaf9a8 100644 --- a/netbox/dcim/migrations/0003_squashed_0130.py +++ b/netbox/dcim/migrations/0003_squashed_0130.py @@ -165,7 +165,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='frontporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='frontporttemplate', @@ -185,7 +185,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='frontport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='frontport', @@ -210,12 +210,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='devicebaytemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebaytemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='devicebay', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebays', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='devicebay', @@ -290,7 +290,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleserverporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='consoleserverport', @@ -310,7 +310,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleserverport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='consoleserverport', @@ -320,7 +320,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='consoleport', @@ -340,7 +340,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='consoleport', diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py index cdc7960a4..0b0282c80 100644 --- a/netbox/dcim/migrations/0146_modules.py +++ b/netbox/dcim/migrations/0146_modules.py @@ -45,37 +45,37 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='consoleporttemplate', name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AlterField( model_name='consoleserverporttemplate', name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AlterField( model_name='frontporttemplate', name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AlterField( model_name='interfacetemplate', name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.devicetype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AlterField( model_name='poweroutlettemplate', name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.devicetype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AlterField( model_name='powerporttemplate', name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AlterField( model_name='rearporttemplate', name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.CreateModel( name='ModuleType', @@ -107,7 +107,7 @@ class Migration(migrations.Migration): ('label', models.CharField(blank=True, max_length=64)), ('position', models.CharField(blank=True, max_length=30)), ('description', models.CharField(blank=True, max_length=200)), - ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ @@ -138,72 +138,72 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleport', name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.module'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), ), migrations.AddField( model_name='consoleporttemplate', name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.moduletype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), ), migrations.AddField( model_name='consoleserverport', name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.module'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), ), migrations.AddField( model_name='consoleserverporttemplate', name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.moduletype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), ), migrations.AddField( model_name='frontport', name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.module'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), ), migrations.AddField( model_name='frontporttemplate', name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.moduletype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), ), migrations.AddField( model_name='interface', name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.module'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), ), migrations.AddField( model_name='interfacetemplate', name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.moduletype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), ), migrations.AddField( model_name='poweroutlet', name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.module'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), ), migrations.AddField( model_name='poweroutlettemplate', name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.moduletype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), ), migrations.AddField( model_name='powerport', name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.module'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), ), migrations.AddField( model_name='powerporttemplate', name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.moduletype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), ), migrations.AddField( model_name='rearport', name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.module'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), ), migrations.AddField( model_name='rearporttemplate', name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.moduletype'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), ), migrations.AlterUniqueTogether( name='consoleporttemplate', @@ -244,7 +244,7 @@ class Migration(migrations.Migration): ('label', models.CharField(blank=True, max_length=64)), ('position', models.CharField(blank=True, max_length=30)), ('description', models.CharField(blank=True, max_length=200)), - ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebaytemplates', to='dcim.devicetype')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), ], options={ 'ordering': ('device_type', '_name'), diff --git a/netbox/dcim/migrations/0149_inventoryitem_templates.py b/netbox/dcim/migrations/0149_inventoryitem_templates.py index 7ef347680..f0b1f3cff 100644 --- a/netbox/dcim/migrations/0149_inventoryitem_templates.py +++ b/netbox/dcim/migrations/0149_inventoryitem_templates.py @@ -30,7 +30,7 @@ class Migration(migrations.Migration): ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), ('level', models.PositiveIntegerField(editable=False)), ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), - ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitemtemplates', to='dcim.devicetype')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')), ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')), ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')), diff --git a/netbox/extras/migrations/0001_squashed.py b/netbox/extras/migrations/0001_squashed.py index 916dab7b4..ea89400e1 100644 --- a/netbox/extras/migrations/0001_squashed.py +++ b/netbox/extras/migrations/0001_squashed.py @@ -99,8 +99,8 @@ class Migration(migrations.Migration): fields=[ ('object_id', models.IntegerField(db_index=True)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_tagged_items', to='contenttypes.contenttype')), - ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_items', to='extras.tag')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_tagged_items', to='contenttypes.contenttype')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='extras.tag')), ], ), migrations.CreateModel( diff --git a/netbox/extras/migrations/0002_squashed_0059.py b/netbox/extras/migrations/0002_squashed_0059.py index 9098d286f..98bed255a 100644 --- a/netbox/extras/migrations/0002_squashed_0059.py +++ b/netbox/extras/migrations/0002_squashed_0059.py @@ -75,57 +75,57 @@ class Migration(migrations.Migration): migrations.AddField( model_name='configcontext', name='cluster_groups', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_cluster_groups_+', to='virtualization.ClusterGroup'), + field=models.ManyToManyField(blank=True, related_name='+', to='virtualization.ClusterGroup'), ), migrations.AddField( model_name='configcontext', name='clusters', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_clusters_+', to='virtualization.Cluster'), + field=models.ManyToManyField(blank=True, related_name='+', to='virtualization.Cluster'), ), migrations.AddField( model_name='configcontext', name='device_types', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_device_types_+', to='dcim.DeviceType'), + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.DeviceType'), ), migrations.AddField( model_name='configcontext', name='platforms', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_platforms_+', to='dcim.Platform'), + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.Platform'), ), migrations.AddField( model_name='configcontext', name='regions', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_regions_+', to='dcim.Region'), + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.Region'), ), migrations.AddField( model_name='configcontext', name='roles', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_roles_+', to='dcim.DeviceRole'), + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.DeviceRole'), ), migrations.AddField( model_name='configcontext', name='site_groups', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'), + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.SiteGroup'), ), migrations.AddField( model_name='configcontext', name='sites', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_sites_+', to='dcim.Site'), + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.Site'), ), migrations.AddField( model_name='configcontext', name='tags', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_tags_+', to='extras.Tag'), + field=models.ManyToManyField(blank=True, related_name='+', to='extras.Tag'), ), migrations.AddField( model_name='configcontext', name='tenant_groups', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_tenant_groups_+', to='tenancy.TenantGroup'), + field=models.ManyToManyField(blank=True, related_name='+', to='tenancy.TenantGroup'), ), migrations.AddField( model_name='configcontext', name='tenants', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_tenants_+', to='tenancy.Tenant'), + field=models.ManyToManyField(blank=True, related_name='+', to='tenancy.Tenant'), ), migrations.AlterUniqueTogether( name='webhook', diff --git a/netbox/extras/migrations/0068_configcontext_cluster_types.py b/netbox/extras/migrations/0068_configcontext_cluster_types.py index 40c7887e3..abe90013e 100644 --- a/netbox/extras/migrations/0068_configcontext_cluster_types.py +++ b/netbox/extras/migrations/0068_configcontext_cluster_types.py @@ -12,6 +12,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='configcontext', name='cluster_types', - field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_cluster_types_+', to='virtualization.ClusterType'), + field=models.ManyToManyField(blank=True, related_name='+', to='virtualization.ClusterType'), ), ] From 630ff2abb4f9368bb2329579b0b0d6c00af3bd3d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 13:31:53 -0500 Subject: [PATCH 142/271] Remove dependency on is_safe_url() --- netbox/netbox/views/generic/object_views.py | 10 +++------- netbox/users/views.py | 19 +++++++++---------- netbox/utilities/views.py | 9 +++------ 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 316c3a1ee..a078f565c 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -9,7 +9,6 @@ from django.forms.widgets import HiddenInput from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import escape -from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from extras.signals import clear_webhooks @@ -259,9 +258,7 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): if '_addanother' in request.POST: return redirect(request.get_full_path()) - return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): - return redirect(return_url) + self.get_return_url(request, obj) return redirect(self.get_return_url(request, obj)) else: @@ -507,10 +504,9 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): messages.success(request, msg) return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): + if return_url and return_url.startswith('/'): return redirect(return_url) - else: - return redirect(self.get_return_url(request, obj)) + return redirect(self.get_return_url(request, obj)) else: logger.debug("Form validation failed") diff --git a/netbox/users/views.py b/netbox/users/views.py index cd3c34aa9..43c65eada 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -10,7 +10,6 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.decorators import method_decorator -from django.utils.http import is_safe_url from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View from social_core.backends.utils import load_backends @@ -78,17 +77,17 @@ class LoginView(View): }) def redirect_to_next(self, request, logger): - if request.method == "POST": - redirect_to = request.POST.get('next', settings.LOGIN_REDIRECT_URL) + data = request.POST if request.method == "POST" else request.GET + redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL) + + if redirect_url and redirect_url.startswith('/'): + logger.debug(f"Redirecting user to {redirect_url}") else: - redirect_to = request.GET.get('next', settings.LOGIN_REDIRECT_URL) + if redirect_url: + logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}") + redirect_url = reverse('home') - if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): - logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") - redirect_to = reverse('home') - - logger.debug(f"Redirecting user to {redirect_to}") - return HttpResponseRedirect(redirect_to) + return HttpResponseRedirect(redirect_url) class LogoutView(View): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index efea0b867..858e7b491 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,10 +1,7 @@ from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import ImproperlyConfigured -from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.urls.exceptions import NoReverseMatch -from django.utils.http import is_safe_url -from django.views.generic import View from .permissions import resolve_permission @@ -103,9 +100,9 @@ class GetReturnURLMixin: # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's # considered safe. - query_param = request.GET.get('return_url') or request.POST.get('return_url') - if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()): - return query_param + return_url = request.GET.get('return_url') or request.POST.get('return_url') + if return_url and return_url.startswith('/'): + return return_url # Next, check if the object being modified (if any) has an absolute URL. if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): From 0e95ca7b69148525cedd91c99dfaa81fc3d68332 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 1 Feb 2022 14:10:25 -0500 Subject: [PATCH 143/271] Fix ProgrammingError exception when annotating config context data --- netbox/extras/querysets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 982f33d02..21727d3d4 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -82,7 +82,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): self._get_config_context_filters() ).annotate( _data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name']) - ).values("_data") + ).values("_data").order_by() ) ).distinct() From 6575af6b93f32a2b2bac93458ef26621797ed0e8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Feb 2022 13:25:27 -0500 Subject: [PATCH 144/271] Fix saving interfaces --- netbox/templates/dcim/interface_edit.html | 7 ++++++- .../virtualization/vminterface_edit.html | 15 ++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index e45cdd685..4c7545ee6 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -2,13 +2,18 @@ {% load form_helpers %} {% block form %} + {# Render hidden fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    Interface
    {% if form.instance.device %}
    - +
    diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index 687c6c090..de8825574 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -2,17 +2,22 @@ {% load form_helpers %} {% block form %} + {# Render hidden fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    Interface
    {% if form.instance.virtual_machine %} -
    - -
    - -
    +
    + +
    +
    +
    {% endif %} {% render_field form.name %} {% render_field form.description %} From ac1c0b071550a3ab30f0a4fa5b3104f71cc9cd40 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 3 Feb 2022 13:52:42 -0500 Subject: [PATCH 145/271] #8054: Allow replacing default static choices --- docs/configuration/optional-settings.md | 16 ++++++++++++---- netbox/utilities/choices.py | 14 ++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 081e4b776..384d368fa 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -174,11 +174,11 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] ## FIELD_CHOICES -Default: Empty dictionary +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) -Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices list. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) +The choices provided can either replace the stock choices provided by NetBox, or append to them. To _replace_ the available choices, specify the app, model, and field name separated by dots. For example, the site model would be referenced as `dcim.Site.status`. To _extend_ the available choices, append a plus sign to the end of this string (e.g. `dcim.Site.status+`). -For example, to specify a custom set of choices for the site status field: +For example, the following configuration would replace the default site status choices with the options Foo, Bar, and Baz: ```python FIELD_CHOICES = { @@ -190,7 +190,15 @@ FIELD_CHOICES = { } ``` -These will be appended to the stock choices for the field. +Appending a plus sign to the field identifier would instead _add_ these choices to the ones already offered: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ... + ) +} +``` The following model field support configurable choices: diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index b500f5b9d..415c11ddf 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -8,12 +8,14 @@ class ChoiceSetMeta(type): def __new__(mcs, name, bases, attrs): # Extend static choices with any configured choices - key = attrs.get('key') - if key: - try: - attrs['CHOICES'].extend(settings.FIELD_CHOICES[key]) - except KeyError: - pass + replace_key = attrs.get('key') + extend_key = f'{replace_key}+' if replace_key else None + if replace_key and replace_key in settings.FIELD_CHOICES: + # Replace the stock choices + attrs['CHOICES'] = settings.FIELD_CHOICES[replace_key] + elif extend_key and extend_key in settings.FIELD_CHOICES: + # Extend the stock choices + attrs['CHOICES'].extend(settings.FIELD_CHOICES[extend_key]) # Define choice tuples and color maps attrs['_choices'] = [] From 60e87cd4966c2478bf13c8ea53a969604e79fff3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Feb 2022 09:59:53 -0500 Subject: [PATCH 146/271] Enable the use of fieldsets on bulk edit forms --- netbox/circuits/forms/bulk_edit.py | 15 ++- netbox/dcim/forms/bulk_edit.py | 109 +++++++++++++++++- netbox/ipam/forms/bulk_edit.py | 44 +++++++ netbox/netbox/forms/base.py | 2 + netbox/netbox/views/generic/bulk_views.py | 2 +- .../templates/generic/object_bulk_edit.html | 72 ++++++++++-- netbox/tenancy/forms/bulk_edit.py | 12 ++ netbox/virtualization/forms/bulk_edit.py | 19 +++ netbox/wireless/forms/bulk_edit.py | 11 ++ 9 files changed, 273 insertions(+), 13 deletions(-) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 8c1502d84..8fc76e940 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -44,6 +44,9 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): ) model = Provider + fieldsets = ( + (None, ('asn', 'account', 'portal_url', 'noc_contact', 'admin_contact')), + ) nullable_fields = ( 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', ) @@ -56,7 +59,8 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): ) service_id = forms.CharField( max_length=100, - required=False + required=False, + label='Service ID' ) description = forms.CharField( max_length=200, @@ -68,6 +72,9 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): ) model = ProviderNetwork + fieldsets = ( + (None, ('provider', 'service_id', 'description')), + ) nullable_fields = ( 'service_id', 'description', 'comments', ) @@ -80,6 +87,9 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): ) model = CircuitType + fieldsets = ( + (None, ('description',)), + ) nullable_fields = ('description',) @@ -116,6 +126,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): ) model = Circuit + fieldsets = ( + (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')), + ) nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 787f0339a..41982480d 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -68,6 +68,9 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): ) model = Region + fieldsets = ( + (None, ('parent', 'description')), + ) nullable_fields = ('parent', 'description') @@ -82,6 +85,9 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): ) model = SiteGroup + fieldsets = ( + (None, ('parent', 'description')), + ) nullable_fields = ('parent', 'description') @@ -120,6 +126,9 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): ) model = Site + fieldsets = ( + (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), + ) nullable_fields = ( 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', ) @@ -147,6 +156,9 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): ) model = Location + fieldsets = ( + (None, ('site', 'parent', 'tenant', 'description')), + ) nullable_fields = ('parent', 'tenant', 'description') @@ -160,6 +172,9 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): ) model = RackRole + fieldsets = ( + (None, ('color', 'description')), + ) nullable_fields = ('color', 'description') @@ -254,6 +269,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): ) 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')), + ) nullable_fields = ( 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ) @@ -277,6 +297,9 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): ) model = RackReservation + fieldsets = ( + (None, ('user', 'tenant', 'description')), + ) class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): @@ -286,6 +309,9 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): ) model = Manufacturer + fieldsets = ( + (None, ('description',)), + ) nullable_fields = ('description',) @@ -313,6 +339,9 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): ) model = DeviceType + fieldsets = ( + (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), + ) nullable_fields = ('part_number', 'airflow') @@ -326,6 +355,9 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): ) model = ModuleType + fieldsets = ( + (None, ('manufacturer', 'part_number')), + ) nullable_fields = ('part_number',) @@ -344,6 +376,9 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): ) model = DeviceRole + fieldsets = ( + (None, ('color', 'vm_role', 'description')), + ) nullable_fields = ('color', 'description') @@ -363,6 +398,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): ) model = Platform + fieldsets = ( + (None, ('manufacturer', 'napalm_driver', 'description')), + ) nullable_fields = ('manufacturer', 'napalm_driver', 'description') @@ -418,6 +456,11 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): ) model = Device + fieldsets = ( + ('Device', ('device_role', 'status', 'tenant', 'platform')), + ('Location', ('site', 'location')), + ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), + ) nullable_fields = ( 'tenant', 'platform', 'serial', 'airflow', ) @@ -442,6 +485,9 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): ) model = Module + fieldsets = ( + (None, ('manufacturer', 'module_type', 'serial')), + ) nullable_fields = ('serial',) @@ -481,6 +527,10 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): ) model = Cable + fieldsets = ( + (None, ('type', 'status', 'tenant', 'label')), + ('Attributes', ('color', 'length', 'length_unit')), + ) nullable_fields = ( 'type', 'status', 'tenant', 'label', 'color', 'length', ) @@ -504,6 +554,9 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): ) model = VirtualChassis + fieldsets = ( + (None, ('domain',)), + ) nullable_fields = ('domain',) @@ -539,6 +592,9 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): ) model = PowerPanel + fieldsets = ( + (None, ('region', 'site_group', 'site', 'location')), + ) nullable_fields = ('location',) @@ -594,6 +650,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): ) model = PowerFeed + fieldsets = ( + (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected')), + ('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) + ) nullable_fields = ('location', 'comments') @@ -862,6 +922,9 @@ class ConsolePortBulkEditForm( ) model = ConsolePort + fieldsets = ( + (None, ('type', 'label', 'speed', 'description', 'mark_connected')), + ) nullable_fields = ('label', 'description') @@ -875,6 +938,9 @@ class ConsoleServerPortBulkEditForm( ) model = ConsoleServerPort + fieldsets = ( + (None, ('type', 'label', 'speed', 'description', 'mark_connected')), + ) nullable_fields = ('label', 'description') @@ -888,6 +954,10 @@ class PowerPortBulkEditForm( ) model = PowerPort + fieldsets = ( + (None, ('type', 'label', 'description', 'mark_connected')), + ('Power', ('maximum_draw', 'allocated_draw')), + ) nullable_fields = ('label', 'description') @@ -907,6 +977,10 @@ class PowerOutletBulkEditForm( ) model = PowerOutlet + fieldsets = ( + (None, ('type', 'label', 'description', 'mark_connected')), + ('Power', ('feed_leg', 'power_port')), + ) nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): @@ -923,8 +997,9 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', - 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'tx_power', ]), NetBoxModelBulkEditForm ): @@ -956,7 +1031,7 @@ class InterfaceBulkEditForm( ) speed = forms.IntegerField( required=False, - widget=SelectSpeedWidget(attrs={'readonly': None}), + widget=SelectSpeedWidget(), label='Speed' ) mgmt_only = forms.NullBooleanField( @@ -983,6 +1058,14 @@ class InterfaceBulkEditForm( ) model = Interface + fieldsets = ( + (None, ('type', 'label', 'speed', 'duplex', 'description')), + ('Addressing', ('vrf', 'mac_address', 'wwn')), + ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), + ) nullable_fields = ( 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', @@ -1047,6 +1130,9 @@ class FrontPortBulkEditForm( NetBoxModelBulkEditForm ): model = FrontPort + fieldsets = ( + (None, ('type', 'label', 'color', 'description', 'mark_connected')), + ) nullable_fields = ('label', 'description') @@ -1055,14 +1141,20 @@ class RearPortBulkEditForm( NetBoxModelBulkEditForm ): model = RearPort + fieldsets = ( + (None, ('type', 'label', 'color', 'description', 'mark_connected')), + ) nullable_fields = ('label', 'description') class ModuleBayBulkEditForm( - form_from_model(DeviceBay, ['label', 'description']), + form_from_model(ModuleBay, ['label', 'position', 'description']), NetBoxModelBulkEditForm ): model = ModuleBay + fieldsets = ( + (None, ('label', 'position', 'description')), + ) nullable_fields = ('label', 'position', 'description') @@ -1071,6 +1163,9 @@ class DeviceBayBulkEditForm( NetBoxModelBulkEditForm ): model = DeviceBay + fieldsets = ( + (None, ('label', 'description')), + ) nullable_fields = ('label', 'description') @@ -1088,6 +1183,9 @@ class InventoryItemBulkEditForm( ) model = InventoryItem + fieldsets = ( + (None, ('label', 'role', 'manufacturer', 'part_id', 'description')), + ) nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') @@ -1105,4 +1203,7 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): ) model = InventoryItemRole + fieldsets = ( + (None, ('color', 'description')), + ) nullable_fields = ('color', 'description') diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 3e7bc88a4..66b4ba0fc 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -46,6 +46,9 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): ) model = VRF + fieldsets = ( + (None, ('tenant', 'enforce_unique', 'description')), + ) nullable_fields = ('tenant', 'description') @@ -60,6 +63,9 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): ) model = RouteTarget + fieldsets = ( + (None, ('tenant', 'description')), + ) nullable_fields = ('tenant', 'description') @@ -74,6 +80,9 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): ) model = RIR + fieldsets = ( + (None, ('is_private', 'description')), + ) nullable_fields = ('is_private', 'description') @@ -97,6 +106,9 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): ) model = ASN + fieldsets = ( + (None, ('sites', 'rir', 'tenant', 'description')), + ) nullable_fields = ('date_added', 'description') @@ -119,6 +131,9 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): ) model = Aggregate + fieldsets = ( + (None, ('rir', 'tenant', 'date_added', 'description')), + ) nullable_fields = ('date_added', 'description') @@ -132,6 +147,9 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): ) model = Role + fieldsets = ( + (None, ('weight', 'description')), + ) nullable_fields = ('description',) @@ -191,6 +209,11 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): ) model = Prefix + fieldsets = ( + (None, ('tenant', 'status', 'role', 'description')), + ('Site', ('region', 'site_group', 'site')), + ('Addressing', ('vrf', 'prefix_length', 'is_pool', 'mark_utilized')), + ) nullable_fields = ( 'site', 'vrf', 'tenant', 'role', 'description', ) @@ -221,6 +244,9 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): ) model = IPRange + fieldsets = ( + (None, ('status', 'role', 'vrf', 'tenant', 'description')), + ) nullable_fields = ( 'vrf', 'tenant', 'role', 'description', ) @@ -262,6 +288,10 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): ) model = IPAddress + fieldsets = ( + (None, ('status', 'role', 'tenant', 'description')), + ('Addressing', ('vrf', 'mask_length', 'dns_name')), + ) nullable_fields = ( 'vrf', 'role', 'tenant', 'dns_name', 'description', ) @@ -295,6 +325,10 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): ) model = FHRPGroup + fieldsets = ( + (None, ('protocol', 'group_id', 'description')), + ('Authentication', ('auth_type', 'auth_key')), + ) nullable_fields = ('auth_type', 'auth_key', 'description') @@ -321,6 +355,9 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): ) model = VLANGroup + fieldsets = ( + (None, ('site', 'min_vid', 'max_vid', 'description')), + ) nullable_fields = ('site', 'description') @@ -367,6 +404,10 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ) model = VLAN + fieldsets = ( + (None, ('status', 'role', 'tenant', 'description')), + ('Site & Group', ('region', 'site_group', 'site', 'group')), + ) nullable_fields = ( 'site', 'group', 'tenant', 'role', 'description', ) @@ -391,6 +432,9 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): ) model = ServiceTemplate + fieldsets = ( + (None, ('protocol', 'ports', 'description')), + ) nullable_fields = ('description',) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index a68548256..0666fbe58 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -67,6 +67,8 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): fields and adding/removing tags. Attributes: + fieldsets: An iterable of two-tuples which define a heading and field set to display per section of + the rendered form (optional). If not defined, the all fields will be rendered as a single section. nullable_fields: A list of field names indicating which fields support being set to null/empty """ nullable_fields = () diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index a3e9b48c1..8a6d1e49f 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -578,9 +578,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): return redirect(self.get_return_url(request)) return render(request, self.template_name, { + 'model': model, 'form': form, 'table': table, - 'obj_type_plural': model._meta.verbose_name_plural, 'return_url': self.get_return_url(request), **self.get_extra_context(request), }) diff --git a/netbox/templates/generic/object_bulk_edit.html b/netbox/templates/generic/object_bulk_edit.html index 31bc07ac4..6d286f58c 100644 --- a/netbox/templates/generic/object_bulk_edit.html +++ b/netbox/templates/generic/object_bulk_edit.html @@ -3,7 +3,7 @@ {% load form_helpers %} {% load render_table from django_tables2 %} -{% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %} +{% block title %}Editing {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}{% endblock %} {% block tabs %}
    + + + + diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index de8825574..bc479e9d9 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -22,6 +22,7 @@ {% render_field form.name %} {% render_field form.description %} {% render_field form.mac_address %} + {% render_field form.vrf %} {% render_field form.mtu %} {% render_field form.tags %} {% render_field form.enabled %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 866b8f9bb..3d3451062 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import PrimaryModelSerializer @@ -116,6 +116,7 @@ class VMInterfaceSerializer(PrimaryModelSerializer): required=False, many=True ) + vrf = NestedVRFSerializer(required=False, allow_null=True) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) @@ -123,8 +124,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer): model = VMInterface fields = [ 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', - 'count_ipaddresses', 'count_fhrp_groups', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', ] def validate(self, data): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 894045c1a..471589ba5 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -80,7 +80,8 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) class VMInterfaceViewSet(ModelViewSet): queryset = VMInterface.objects.prefetch_related( - 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', + 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', + 'fhrp_group_assignments', ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filtersets.VMInterfaceFilterSet diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 28b23e8a8..6eae56c13 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -3,6 +3,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet +from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter @@ -273,6 +274,17 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet): mac_address = MultiValueMACAddressFilter( label='MAC address', ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='vrf', + queryset=VRF.objects.all(), + label='VRF', + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) class Meta: model = VMInterface diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 6bd2f2d4e..d5d33df2a 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -3,7 +3,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import VLAN +from ipam.models import VLAN, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -190,15 +190,20 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): queryset=VLAN.objects.all(), required=False ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) model = VMInterface fieldsets = ( - (None, ('mtu', 'enabled', 'description')), + (None, ('mtu', 'enabled', 'vrf', 'description')), ('Related Interfaces', ('parent', 'bridge')), ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), ) nullable_fields = ( - 'parent', 'bridge', 'mtu', 'description', + 'parent', 'bridge', 'mtu', 'vrf', 'description', ) def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index cefc2219d..aa1b203e3 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,5 +1,6 @@ from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site +from ipam.models import VRF from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField @@ -121,11 +122,18 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm): required=False, help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Assigned VRF' + ) class Meta: model = VMInterface fields = ( 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'vrf', ) def clean_enabled(self): diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 8e3dcd143..7702a23ae 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext as _ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm +from ipam.models import VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( @@ -157,7 +158,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Virtual Machine', ('cluster_id', 'virtual_machine_id')), - ('Attributes', ('enabled', 'mac_address')), + ('Attributes', ('enabled', 'mac_address', 'vrf_id')), ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), @@ -182,4 +183,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm): required=False, label='MAC address' ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tag = TagFilterField(model) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index ecd909ec2..e488ac23a 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -6,7 +6,7 @@ from dcim.forms.common import InterfaceCommonForm from dcim.forms.models import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup +from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( @@ -313,6 +313,11 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'available_on_virtualmachine': '$virtual_machine', } ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -322,7 +327,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'tags', 'untagged_vlan', 'tagged_vlans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'virtual_machine': forms.HiddenInput(), diff --git a/netbox/virtualization/migrations/0028_vminterface_vrf.py b/netbox/virtualization/migrations/0028_vminterface_vrf.py new file mode 100644 index 000000000..a188e1c60 --- /dev/null +++ b/netbox/virtualization/migrations/0028_vminterface_vrf.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-02-07 14:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0056_standardize_id_fields'), + ('virtualization', '0027_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vminterfaces', to='ipam.vrf'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index dda1d0bee..42d333d55 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -384,6 +384,14 @@ class VMInterface(NetBoxModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.SET_NULL, + related_name='vminterfaces', + null=True, + blank=True, + verbose_name='VRF' + ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', content_type_field='interface_type', diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index e1156627a..4dc6bb917 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -168,6 +168,9 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) + vrf = tables.Column( + linkify=True + ) tags = columns.TagColumn( url_name='virtualization:vminterface_list' ) @@ -176,7 +179,7 @@ class VMInterfaceTable(BaseInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 4a9b67bf0..f6c07fa54 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,7 +2,7 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices -from ipam.models import VLAN +from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -234,6 +234,13 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): ) VLAN.objects.bulk_create(vlans) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) + cls.create_data = [ { 'virtual_machine': virtualmachine.pk, @@ -241,6 +248,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'mode': InterfaceModeChoices.MODE_TAGGED, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'vrf': vrfs[0].pk, }, { 'virtual_machine': virtualmachine.pk, @@ -249,6 +257,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'vrf': vrfs[1].pk, }, { 'virtual_machine': virtualmachine.pk, @@ -257,5 +266,6 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'vrf': vrfs[2].pk, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 8c8f6671f..bcd2c4699 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import IPAddress +from ipam.models import IPAddress, VRF from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests from virtualization.choices import * @@ -414,6 +414,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Cluster.objects.bulk_create(clusters) + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + VRF(name='VRF 3', rd='65000:3'), + ) + VRF.objects.bulk_create(vrfs) + vms = ( VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]), @@ -422,9 +429,9 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): VirtualMachine.objects.bulk_create(vms) interfaces = ( - VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'), - VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'), - VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'), + VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01', vrf=vrfs[0]), + VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02', vrf=vrfs[1]), + VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03', vrf=vrfs[2]), ) VMInterface.objects.bulk_create(interfaces) @@ -478,3 +485,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): def test_mac_address(self): params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 7dc5660fd..8edc14f00 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -4,7 +4,7 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site -from ipam.models import VLAN +from ipam.models import VLAN, VRF from utilities.testing import ViewTestCases, create_tags from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -263,6 +263,13 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VLAN.objects.bulk_create(vlans) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -276,6 +283,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } @@ -290,14 +298,15 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "virtual_machine,name", - "Virtual Machine 2,Interface 4", - "Virtual Machine 2,Interface 5", - "Virtual Machine 2,Interface 6", + f"virtual_machine,name,vrf.pk", + f"Virtual Machine 2,Interface 4,{vrfs[0].pk}", + f"Virtual Machine 2,Interface 5,{vrfs[0].pk}", + f"Virtual Machine 2,Interface 6,{vrfs[0].pk}", ) cls.bulk_edit_data = { From 2157f93f364affcc2367f2e10c17950be25bc5a5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 7 Feb 2022 10:47:07 -0500 Subject: [PATCH 153/271] Clean up merge conflict remnants --- docs/release-notes/version-3.1.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 7536ebeb4..27aaa4b4c 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,8 +1,5 @@ # NetBox v3.1 -<<<<<<< HEAD -## v3.1.7 (FUTURE) -======= ## v3.1.7 (2022-02-03) ### Enhancements @@ -29,7 +26,6 @@ * [#8499](https://github.com/netbox-community/netbox/issues/8499) - Content types REST API endpoint should not require model permission * [#8512](https://github.com/netbox-community/netbox/issues/8512) - Correct file permissions to allow execution of housekeeping script * [#8527](https://github.com/netbox-community/netbox/issues/8527) - Fix display of changelog retention period ->>>>>>> develop --- From 9ac769e4f851356cdcd4991542d40cba47339bf7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 7 Feb 2022 11:32:02 -0500 Subject: [PATCH 154/271] Pull graphene-django from v2 branch --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 26a0151e0..1239ed205 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 #graphene_django==2.15.0 # Installing from PR origin temporarily -git+git://github.com/MisterGlass/graphene-django.git +git+git://github.com/graphql-python/graphene-django.git@v2 gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 From 049acde5b0082aa94b7c81d236bd85adcb14f580 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 7 Feb 2022 12:57:02 -0500 Subject: [PATCH 155/271] Closes #8572: Add a pre_run() method for reports --- docs/customization/reports.md | 2 +- docs/release-notes/version-3.2.md | 1 + netbox/extras/reports.py | 13 +++++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/customization/reports.md b/docs/customization/reports.md index ed4faf371..3bf6bd8d9 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -95,7 +95,7 @@ The following methods are available to log results within a report: The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page. -To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. +To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`. By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index f60f6b81e..9b9fd61b5 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -84,6 +84,7 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available- * [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links * [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields +* [#8572](https://github.com/netbox-community/netbox/issues/8572) - Add a `pre_run()` method for reports ### Other Changes diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index f53c0ecd0..2eb6584c9 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -226,6 +226,9 @@ class Report(object): job_result.status = JobResultStatusChoices.STATUS_RUNNING job_result.save() + # Perform any post-run tasks + self.pre_run() + try: for method_name in self.test_methods: @@ -253,8 +256,14 @@ class Report(object): # Perform any post-run tasks self.post_run() - def post_run(self): + def pre_run(self): """ - Extend this method to include any tasks which should execute after the report has been run. + Extend this method to include any tasks which should execute *before* the report is run. + """ + pass + + def post_run(self): + """ + Extend this method to include any tasks which should execute *after* the report is run. """ pass From d816f797a415e2e652b3f311650771b43bedf4af Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 7 Feb 2022 14:31:49 -0500 Subject: [PATCH 156/271] Clean up release notes --- docs/release-notes/version-3.2.md | 101 ++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 9b9fd61b5..8fc733e04 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,80 +8,126 @@ ### Breaking Changes * Automatic redirection of legacy slug-based URL paths has been removed. -* The `asn` field has been removed from the site model. Please use the ASN model introduced in NetBox v3.1 to track ASN assignments for sites. -* The `asn` query filter for sites now matches against the AS number of assigned ASNs. -* The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please use the new contact model introduced in NetBox v3.1 to store contact information for sites. +* The `asn` field has been removed from the site model. Please replicate any site ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. +* The `asn` query filter for sites now matches against the AS number of assigned ASN objects. +* The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. +* A `pre_run()` method has been added to the base Report class. While unlikely to affect most installations, you may need to alter any reports which already use this name for a method. ### New Features #### Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) -NetBox's plugins framework has been extended considerably in this release. Changes include: +NetBox's plugins framework has been extended considerably in this release. Additions include: -* Seven generic view classes are now officially supported for use by plugins. -* `NetBoxModel` is available for subclassing to enable various NetBox features, such as custom fields and change logging. -* `NetBoxModelFilterSet` is available to extend NetBox's dynamic filtering ability to plugin models. +* Officially-supported generic view classes for common CRUD operations: + * `ObjectView` + * `ObjectEditView` + * `ObjectDeleteView` + * `ObjectListView` + * `BulkImportView` + * `BulkEditView` + * `BulkDeleteView` +* The `NetBoxModel` base class, which enables various NetBox features, including: + * Change logging + * Custom fields + * Custom links + * Custom validation + * Export templates + * Journaling + * Tags + * Webhooks +* Four base form classes for manipulating objects via the UI: + * `NetBoxModelForm` + * `NetBoxModelCSVForm` + * `NetBoxModelBulkEditForm` + * `NetBoxModelFilterSetForm` +* The `NetBoxModelFilterSet` base class for plugin filter sets +* The `NetBoxTable` base class for rendering object tables with `django-tables2` +* Plugins can now extend NetBox's GraphQL API with their own schema No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized. #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) -Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. +Several new models have been added to represent field-replaceable device modules, such as line cards installed within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components (interfaces, console ports, etc.) associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. Automatic renaming of module components is also supported. When a new module is created, any occurrence of the string `{module}` in a component name will be replaced with the position of the module bay into which the module is being installed. #### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) -Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. +Two new types of custom field have been introduced: object and multi-object. These can be used to associate an object in NetBox with some other arbitrary object(s) regardless of its type. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. -Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object. +Custom field object assignment is fully supported in the REST API, and functions similarly to built-in foreign key relations. Nested representations are provided automatically for each custom field object. #### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) -Custom choices can be now added to most status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field: +Custom choices can be now added to most status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices an (optionally) colors. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field, each with a designated color: ```python FIELD_CHOICES = { 'dcim.Site.status': ( - ('foo', 'Foo'), - ('bar', 'Bar'), - ('baz', 'Baz'), + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), ) } ``` +This will replace all default choices for this field with those listed. If instead the intent is to _extend_ the current choices, this can be done by adding a plus sign (`+`) to the end of the field identifier. For example, the following will add a single extra choice while retaining the defaults provided by NetBox: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ('fubar', 'FUBAR', 'red'), + ) +} +``` + +#### Improved User Preferences ([#7759](https://github.com/netbox-community/netbox/issues/7759)) + +A robust new mechanism for managing user preferences is included in this release. The user preferences form has been improved for better usability, and administrators can now define default preferences for all users with the [`DEFAULT_USER_PREFERENCES`](../configuration/dynamic-settings.md##default_user_preferences) configuration parameter. For example, this can be used to define the columns which appear by default in a table: + +```python +DEFAULT_USER_PREFERENCES = { + 'tables': { + 'IPAddressTable': { + 'columns': ['address', 'status', 'created', 'description'] + } + } +} +``` + #### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) -A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. +A new model has been introduced to represent functional roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. #### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) -Inventory items can now be templatized on a device type similar to the other component types. This enables users to better pre-model fixed hardware components. +Inventory items can now be templatized on a device type similar to other components (such as interfaces or console ports). This enables users to better pre-model fixed hardware components. -Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other components. These relationships will be mirrored when instantiating inventory items on a newly-created device. +Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other templated components. These relationships will be mirrored when instantiating inventory items on a newly-created device. For example, if defining an optic assigned to an interface template on a device type, the instantiated device will mimic this relationship between the optic and interface. #### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) -A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. +A new service template model has been introduced to assist in standardizing the definition and association of layer four services with devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. #### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) -A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. +A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically from the available pool. ### Enhancements -* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators +* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table pagination controls * [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation * [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables * [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks -* [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts * [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components -* [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs -* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to interface model +* [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable the assignment of interfaces to VRFs +* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to device interface model * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group -* [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized +* [#8295](https://github.com/netbox-community/netbox/issues/8295) - Jinja2 rendering is now supported for webhook URLs * [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links * [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields * [#8572](https://github.com/netbox-community/netbox/issues/8572) - Add a `pre_run()` method for reports @@ -104,7 +150,8 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available- * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` * `/api/dcim/module-types/` - * `/api/extras/service-templates/` + * `/api/ipam/service-templates/` + * `/api/ipam/vlan-groups//available-vlans/` * circuits.ProviderNetwork * Added `service_id` field * dcim.ConsolePort @@ -117,7 +164,7 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available- * Added `module`, `speed`, `duplex`, and `vrf` fields * dcim.InventoryItem * Added `component_type`, `component_id`, and `role` fields - * Added read-only `component` field + * Added read-only `component` field (GFK) * dcim.PowerPort * Added `module` field * dcim.PowerOutlet @@ -129,7 +176,7 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//available- * extras.ConfigContext * Add `cluster_types` field * extras.CustomField - * Added `object_type` field + * Added `data_type` and `object_type` fields * extras.CustomLink * Added `enabled` field * ipam.VLANGroup From 26db326483c7c3c084a1f4f02a07884a86e9a9e9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 7 Feb 2022 15:09:09 -0500 Subject: [PATCH 157/271] Fix field group header --- netbox/templates/generic/object_bulk_edit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/generic/object_bulk_edit.html b/netbox/templates/generic/object_bulk_edit.html index 6d286f58c..b955a5162 100644 --- a/netbox/templates/generic/object_bulk_edit.html +++ b/netbox/templates/generic/object_bulk_edit.html @@ -48,7 +48,7 @@
    - {% if group %}{{ group }}{% else %}{{ model|meta:"verbose_name" }}{% endif %} + {% if group %}{{ group }}{% else %}{{ model|meta:"verbose_name"|bettertitle }}{% endif %}
    {% for name in fields %} From 624eda297f1e240061b02997ddec2b19ef99f1cf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 7 Feb 2022 16:50:17 -0500 Subject: [PATCH 158/271] Clean up and document object, object list templates --- netbox/dcim/views.py | 7 +-- netbox/netbox/views/generic/bulk_views.py | 4 +- netbox/templates/dcim/device/base.html | 8 +--- netbox/templates/dcim/devicetype/base.html | 8 +--- netbox/templates/dcim/moduletype.html | 2 - netbox/templates/dcim/moduletype/base.html | 8 +--- netbox/templates/generic/object.html | 28 ++++++++++-- netbox/templates/generic/object_list.html | 53 ++++++++++++++-------- netbox/templates/ipam/aggregate/base.html | 7 +-- netbox/templates/ipam/iprange/base.html | 7 +-- netbox/templates/ipam/prefix/base.html | 7 +-- 11 files changed, 67 insertions(+), 72 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c70f33074..1df1eb1ac 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -803,7 +803,6 @@ class DeviceTypeView(generic.ObjectView): return { 'instance_count': instance_count, - 'active_tab': 'devicetype', } @@ -953,11 +952,10 @@ class ModuleTypeView(generic.ObjectView): queryset = ModuleType.objects.prefetch_related('manufacturer') def get_extra_context(self, request, instance): - # instance_count = Module.objects.restrict(request.user).filter(device_type=instance).count() + instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count() return { - # 'instance_count': instance_count, - 'active_tab': 'moduletype', + 'instance_count': instance_count, } @@ -1570,7 +1568,6 @@ class DeviceView(generic.ObjectView): return { 'services': services, 'vc_members': vc_members, - 'active_tab': 'device', } diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 8a6d1e49f..69ab42199 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -7,7 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django_tables2.export import TableExport @@ -178,7 +178,7 @@ class ObjectListView(BaseMultiObjectView): }) context = { - 'content_type': content_type, + 'model': model, 'table': table, 'permissions': permissions, 'action_buttons': self.action_buttons, diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 4d24717cd..ff139ee4a 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -95,13 +95,7 @@ {% endif %} {% endblock %} -{% block tab_items %} - - +{% block extra_tabs %} {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} {% if active_tab == tab_name or devicebay_count %} - +{% block extra_tabs %} {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} {% if active_tab == tab_name or devicebay_count %}
    - {% comment %} - {% endcomment %}
    AS Number{{ object.asn }}{{ object.asn_with_asdot }}
    RIR
    VRF + {% if object.vrf %} + {{ object.vrf }} + {% else %} + None + {% endif %} +
    Description {{ object.description|placeholder }} Part Number {{ object.part_number|placeholder }}
    Instances {{ instance_count }}
    diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html index 70a447499..f5713efc3 100644 --- a/netbox/templates/dcim/moduletype/base.html +++ b/netbox/templates/dcim/moduletype/base.html @@ -43,13 +43,7 @@ {% endif %} {% endblock %} -{% block tab_items %} - - +{% block extra_tabs %} {% with interface_count=object.interfacetemplates.count %} {% if interface_count %}
  • elements) + object_identifier: Unique identifier for the object + extra_controls: Additional action buttons to display + extra_tabs: Additional tabs to include + content: Page content + +Context: + object: The object instance being viewed +{% endcomment %} + {% block header %}
    {# Breadcrumbs #} @@ -66,11 +78,15 @@ {% block tabs %}